From 2ea5345a7b8a2cd1f47e784e9e977094cb2b8579 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 19:06:42 -0500 Subject: [PATCH 001/849] 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 000000000..5692c5e7a --- /dev/null +++ b/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md @@ -0,0 +1,1170 @@ +# TUI Refactor: Current to Ideal + +Date: 2026-04-01 + +## Scope + +- same repo refactor +- keep Python runtime +- replace PT-based interactive shell +- add Ink UI through a local gateway + +## Current Environment + +Interactive path is centered in `cli.py` with `prompt_toolkit` and `rich`. + +Current technical shape: + +- PT app shell and key handling in `cli.py` + - `Application`, `KeyBindings`, `TextArea`, `patch_stdout` +- queue control in `cli.py` + - `_pending_input` + - `_interrupt_queue` +- approval and sudo callback globals in `tools/terminal_tool.py` + - `_approval_callback` + - `_sudo_password_callback` +- runtime entry in `run_agent.py` + - `AIAgent.run_conversation()` + - `AIAgent.chat()` + +Current constraint: + +- UI logic and runtime control are mixed, so UI replacement is expensive. + +## Ideal Environment + +Interactive path is split into three layers: + +1. Ink UI (Node/TS) +2. local `tui_gateway` over stdio JSON-RPC +3. Python runtime (`AIAgent`, tools, sessions) + +Rules for ideal state: + +- no direct UI to `AIAgent` calls +- no PT dependency in gateway path +- keep current Hermes state/config contracts + - `~/.hermes/.env` + - `~/.hermes/config.yaml` + - `~/.hermes/state.db` + - profile behavior via `HERMES_HOME` + +## Migration Path + +## Cut 1: Headless Controller + +Add: + +- `tui_gateway/controller.py` +- `tui_gateway/session_state.py` +- `tui_gateway/events.py` + +Change: + +- `run_agent.py` callback wiring for controller events +- `cli.py` compatibility bridge into controller + +Done: + +- create/resume/prompt/interrupt/cancel work with no PT imports + +## Cut 2: Local Gateway + +Add: + +- `tui_gateway/protocol.py` +- `tui_gateway/server.py` +- `tui_gateway/entry.py` + +Methods: + +- `session.create` +- `session.resume` +- `session.list` +- `session.interrupt` +- `session.cancel` +- `prompt.submit` +- `approval.respond` +- `sudo.respond` +- `clarify.respond` + +Events: + +- `message.delta` +- `tool.progress` +- `approval.requested` +- `sudo.requested` +- `clarify.requested` +- `error` + +Done: + +- simple client completes full prompt cycle through JSON-RPC + +## Cut 3: Ink UI + +Add: + +- `ui-tui/src/main.tsx` +- `ui-tui/src/gatewayClient.ts` +- `ui-tui/src/state/store.ts` +- `ui-tui/src/components/Transcript.tsx` +- `ui-tui/src/components/Composer.tsx` +- `ui-tui/src/components/StatusBar.tsx` +- `ui-tui/src/components/ApprovalModal.tsx` +- `ui-tui/src/components/SudoPrompt.tsx` +- `ui-tui/src/components/ClarifyPrompt.tsx` + +Change: + +- `tools/terminal_tool.py` prompt adapters for gateway round-trip + +Done: + +- chat, tools, approval, sudo, clarify, interrupt all work through gateway + +## Cut 4: Opt-In and Rollout + +Entry points: + +- `hermes --tui` +- `HERMES_EXPERIMENTAL_TUI=1` +- `display.experimental_tui: true` +- `/tui`, `/tui on`, `/tui off`, `/tui status` + +Behavior: + +- `/tui` starts gateway if needed and attaches +- failed attach falls back to PT mode with explicit error text +- `/tui off` disables auto-launch only + +Rollout: + +1. internal opt-in +2. external opt-in beta +3. default-on after checks pass +4. remove PT path later + +## Acceptance Checks + +- runtime: no PT import in controller/gateway path +- state: same config/profile/session continuity +- commands: slash command registry remains `hermes_cli/commands.py` +- permissions: approval/sudo/clarify protocol round-trip +- streaming: incremental assistant and tool updates +- opt-in: flag/env/config/slash command share one launch path + +## Test Commands + +- `python -m pytest tests/tui_gateway/test_controller.py -q` +- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q` +- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q` +- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q` +- `cd ui-tui && npm run build` +- `cd ui-tui && npm run test` +# Prompt Toolkit to Ink Migration Plan + +Date: 2026-04-01 + +## Scope + +This is a refactor in the same repo. + +- no new repo +- no runtime rewrite +- no messaging gateway reuse for terminal UI + +## Current Environment + +Interactive Hermes today is `prompt_toolkit` plus `rich` inside `cli.py`. + +Current structure: + +- PT app shell and input handling in `cli.py` + - `Application` + - `KeyBindings` + - `TextArea` + - `patch_stdout` +- queue-based control flow in `cli.py` + - `_pending_input` + - `_interrupt_queue` +- approval and sudo callbacks in `tools/terminal_tool.py` + - `_approval_callback` + - `_sudo_password_callback` +- core runtime in `run_agent.py` + - `AIAgent.run_conversation()` + - `AIAgent.chat()` + +Current issue: + +- UI framework logic and runtime control flow are mixed in one path. +- Tool prompt routing depends on PT callback globals. +- Replacing UI without changing runtime is harder than it should be. + +## Ideal Environment + +Interactive Hermes is Ink UI plus a local TUI gateway. + +Target model: + +- Python runtime remains the source of truth. +- UI talks to `tui_gateway` over stdio JSON-RPC. +- `tui_gateway` talks to `AIAgent`. +- no direct UI to `AIAgent` coupling. + +Target compatibility: + +- same `~/.hermes/.env` +- same `~/.hermes/config.yaml` +- same `~/.hermes/state.db` +- same profile behavior through `HERMES_HOME` + +## How To Get There + +Use three delivery cuts and one switch cut. + +## Cut 1: Headless Runtime Controller + +Goal: + +- separate runtime control from PT. + +Add: + +- `tui_gateway/controller.py` +- `tui_gateway/session_state.py` +- `tui_gateway/events.py` + +Change: + +- `run_agent.py` callback wiring needed by controller +- `cli.py` compatibility calls into controller + +Done when: + +- controller supports create/resume/prompt/interrupt/cancel +- controller path imports no PT modules +- tool progress and assistant deltas are typed events + +## Cut 2: Local TUI Gateway + +Goal: + +- add stable protocol boundary for UI. + +Add: + +- `tui_gateway/protocol.py` +- `tui_gateway/server.py` +- `tui_gateway/entry.py` +- `tui_gateway/__init__.py` + +Protocol methods: + +- `session.create` +- `session.resume` +- `session.list` +- `session.interrupt` +- `session.cancel` +- `prompt.submit` +- `approval.respond` +- `sudo.respond` +- `clarify.respond` + +Protocol events: + +- `message.delta` +- `tool.progress` +- `approval.requested` +- `sudo.requested` +- `clarify.requested` +- `error` + +Done when: + +- a simple client can complete one full prompt cycle over stdio JSON-RPC + +## Cut 3: Ink UI + +Goal: + +- usable clone flow through gateway. + +Add: + +- `ui-tui/package.json` +- `ui-tui/src/main.tsx` +- `ui-tui/src/gatewayClient.ts` +- `ui-tui/src/state/store.ts` +- `ui-tui/src/components/Transcript.tsx` +- `ui-tui/src/components/Composer.tsx` +- `ui-tui/src/components/StatusBar.tsx` +- `ui-tui/src/components/ApprovalModal.tsx` +- `ui-tui/src/components/SudoPrompt.tsx` +- `ui-tui/src/components/ClarifyPrompt.tsx` + +Change: + +- `tools/terminal_tool.py` adapters for gateway request/response prompt routing + +Done when: + +- user can chat, run tools, approve, deny, clarify, interrupt, and continue + +## Cut 4: Opt-In Switch and Rollout + +Goal: + +- ship without forced cutover. + +Entry points: + +- `hermes --tui` +- `HERMES_EXPERIMENTAL_TUI=1` +- `display.experimental_tui: true` +- `/tui`, `/tui on`, `/tui off`, `/tui status` + +Behavior: + +- `/tui` starts gateway if needed, then attaches +- attach failure returns to PT mode with clear error text +- `/tui off` disables auto-launch only + +Rollout sequence: + +1. internal opt-in +2. external opt-in beta +3. default-on after checks pass +4. PT path removal later + +## Acceptance Checks + +- runtime + - no PT import in controller or gateway path + - deterministic interrupt/cancel +- state + - same config, profile, and session continuity +- commands + - slash command registry remains centralized in `hermes_cli/commands.py` +- permissions + - approval, sudo, clarify round-trip through protocol +- streaming + - incremental assistant and tool updates +- opt-in + - flag, env, config, and slash command trigger the same launch path + +## Test Commands + +- `python -m pytest tests/tui_gateway/test_controller.py -q` +- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q` +- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q` +- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q` +- `cd ui-tui && npm run build` +- `cd ui-tui && npm run test` + +## Non-Goals + +- no ACP extraction work as prerequisite +- no new repository split +- no direct UI to `AIAgent` coupling +- no PT feature parity before gateway path is stable +# Prompt Toolkit to Ink Migration Plan + +Date: 2026-04-01 + +## Scope + +This is a refactor in the same repo. + +- no new repo +- no runtime rewrite +- no messaging gateway reuse for terminal UI + +## Current Environment (As-Is) + +Interactive Hermes today is `prompt_toolkit` plus `rich` inside `cli.py`. + +Facts from code: + +- PT imports and app shell in `cli.py` + - `Application`, `KeyBindings`, `TextArea`, `patch_stdout` +- PT queue control path in `cli.py` + - `_pending_input` for normal input + - `_interrupt_queue` for input while agent is running +- tool approval and sudo prompts use CLI callbacks in `tools/terminal_tool.py` + - `_sudo_password_callback` + - `_approval_callback` +- core agent runtime is Python in `run_agent.py` + - `AIAgent.run_conversation()` + - `AIAgent.chat()` + +Current coupling problem: + +- UI framework and runtime control flow are mixed in `cli.py`. +- Tool prompts depend on CLI callback globals. +- This blocks clean UI replacement. + +## Ideal Environment (To-Be) + +Interactive Hermes is Ink UI plus a local TUI gateway. + +### Runtime + +- `AIAgent` stays in Python. +- Tool execution stays in Python. +- Session storage and config remain unchanged. + +### Boundary + +- new `tui_gateway` process over stdio JSON-RPC +- UI talks only to gateway +- gateway talks to `AIAgent` + +### UI + +- Node/TypeScript Ink app +- transcript, composer, status, approvals, clarify, sudo, interrupt + +### Compatibility + +Use existing Hermes state and config: + +- `~/.hermes/.env` +- `~/.hermes/config.yaml` +- `~/.hermes/state.db` +- profile behavior via `HERMES_HOME` + +## How To Get There + +Use three implementation cuts plus one switch cut. + +## Cut 1: Headless Runtime Controller + +Goal: separate runtime control flow from PT. + +Add: + +- `tui_gateway/controller.py` +- `tui_gateway/session_state.py` +- `tui_gateway/events.py` + +Change: + +- `run_agent.py` only for callback wiring needed by controller +- `cli.py` to call controller APIs in compatibility mode + +Done when: + +- controller can create/resume/prompt/interrupt/cancel without importing PT +- tool progress and assistant deltas are emitted as typed events + +## Cut 2: Local TUI Gateway + +Goal: protocol boundary between UI and runtime. + +Add: + +- `tui_gateway/protocol.py` +- `tui_gateway/server.py` +- `tui_gateway/entry.py` +- `tui_gateway/__init__.py` + +Protocol methods: + +- `session.create` +- `session.resume` +- `session.list` +- `session.interrupt` +- `session.cancel` +- `prompt.submit` +- `approval.respond` +- `sudo.respond` +- `clarify.respond` + +Protocol events: + +- `message.delta` +- `tool.progress` +- `approval.requested` +- `sudo.requested` +- `clarify.requested` +- `error` + +Done when: + +- a simple client can run one full prompt cycle over stdio JSON-RPC + +## Cut 3: Ink UI + +Goal: usable clone experience through gateway. + +Add: + +- `ui-tui/package.json` +- `ui-tui/src/main.tsx` +- `ui-tui/src/gatewayClient.ts` +- `ui-tui/src/state/store.ts` +- `ui-tui/src/components/Transcript.tsx` +- `ui-tui/src/components/Composer.tsx` +- `ui-tui/src/components/StatusBar.tsx` +- `ui-tui/src/components/ApprovalModal.tsx` +- `ui-tui/src/components/SudoPrompt.tsx` +- `ui-tui/src/components/ClarifyPrompt.tsx` + +Change: + +- `tools/terminal_tool.py` adapters so prompts round-trip through gateway path, not PT-only callbacks + +Done when: + +- user can chat, run tools, approve, deny, clarify, interrupt, and continue + +## Cut 4: Opt-In Switch and Rollout + +Goal: ship safely without forced cutover. + +Entry points: + +- `hermes --tui` +- `HERMES_EXPERIMENTAL_TUI=1` +- `display.experimental_tui: true` +- `/tui`, `/tui on`, `/tui off`, `/tui status` in legacy CLI + +Behavior: + +- `/tui` starts gateway if needed, then attaches +- attach failure returns to PT mode with clear error text +- `/tui off` disables auto-launch only + +Rollout: + +1. internal opt-in +2. external opt-in beta +3. default-on only after acceptance checks pass +4. PT path removal later + +## Acceptance Checks + +- runtime + - no PT import in controller or gateway path + - deterministic interrupt/cancel +- state + - same config, profile, and session continuity +- commands + - slash command registry stays centralized in `hermes_cli/commands.py` +- permissions + - approval, sudo, clarify all round-trip through protocol +- streaming + - incremental assistant and tool updates +- opt-in + - flag, env, config, and slash command all trigger same launch path + +## Test Commands + +- `python -m pytest tests/tui_gateway/test_controller.py -q` +- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q` +- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q` +- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q` +- `cd ui-tui && npm run build` +- `cd ui-tui && npm run test` + +## Non-Goals + +- no ACP extraction work as prerequisite +- no new repository split +- no direct UI to `AIAgent` coupling +- no PT feature parity before gateway path is stable +# Ink Gateway TUI Migration Plan + +Date: 2026-04-01 + +## Goal + +Replace Hermes' interactive `prompt_toolkit` CLI with a React terminal UI built on `Ink`, while keeping the Python agent and tool runtime in place. + +The new design should: + +- remove `prompt_toolkit` from the interactive path entirely +- keep `AIAgent`, tool execution, and session logic in Python +- introduce a transport-neutral local UI gateway between backend and frontend +- use stock `Ink` first, not a Claude Code renderer transplant +- keep using the same Hermes config, profile, skills, memory, and session storage model + +## Decision Summary + +Hermes should not evolve the current `prompt_toolkit` shell. The replacement architecture is: + +1. Python backend session server +2. local gateway transport over stdio JSON-RPC +3. Node/TypeScript `Ink` TUI frontend + +This intentionally uses a dedicated local TUI gateway and keeps `acp_adapter` unchanged. + +The new TUI is a new shell, not a new runtime. + +## Compatibility Requirements + +From the existing Hermes docs and setup flows, the new TUI should continue to use: + +- the same `~/.hermes/.env` provider/auth configuration +- the same `~/.hermes/config.yaml` settings model +- the same `~/.hermes/state.db` session store +- the same `HERMES_HOME` profile layout and isolation rules +- the same skills, memories, and slash-command registry already shared across Hermes surfaces + +The migration should not create: + +- a separate TUI-only config file +- a separate TUI-only session database +- a separate prompt assembly path with drift from existing Hermes runtime behavior + +## Why This Shape + +The current interactive CLI is too coupled to `prompt_toolkit` to incrementally clean up in place: + +- `cli.py` mixes input handling, rendering, approvals, clarify flows, voice, queues, and agent orchestration +- `tools/terminal_tool.py` assumes UI callbacks installed by the CLI +- the current event model is built around PT queues and threads, not a transport-neutral session API + +At the same time, a full port to Claude Code's custom renderer is the wrong first move: + +- Claude Code's TUI stack is not just `Ink`; it includes a product-coupled renderer fork and app bootstrap assumptions +- Hermes does not need that complexity to reach a good first-party TUI +- stock `Ink` is enough to validate the UI model and close the biggest UX gap first + +Operationally, a Node/TypeScript frontend is acceptable here because Hermes already ships with a Node-aware install story and already supports Node-based surfaces in the wider product. + +## Non-Goals + +- reusing the messaging gateway as the TUI transport +- preserving `prompt_toolkit` compatibility +- matching Claude Code internals one-for-one +- rewriting Hermes' core agent or tool runtime in Node + +## High-Level Architecture + +The new interactive stack has three layers: + +1. `python runtime` + Owns `AIAgent`, tool execution, session state, approvals, interrupts, and filesystem/terminal tools. +2. `ui gateway` + A local protocol server that exposes Hermes sessions as typed requests, responses, and events. +3. `ink tui` + A React terminal app that renders transcript, composer, status, approvals, tool cards, and slash-command UX. + +Suggested process model: + +```text +hermes + 1. launch ink tui (node) + 2. spawn python ui gateway over stdio + 3. create/resume session + 4. exchange JSON-RPC requests + streaming events +``` + +## Why Not The Existing Messaging Gateway + +The messaging gateway solves a different problem: + +- multi-platform message routing +- user authorization and pairing +- per-platform delivery behavior +- long-running bot process management + +That stack is useful as architecture background, but it is the wrong seam for a local terminal app. + +The ACP adapter demonstrates the right boundary shape: + +- backend runtime behind a protocol boundary +- callback/event bridging +- permission round-trips +- explicit session lifecycle + +The new local UI gateway should target a Hermes TUI protocol directly, not an editor protocol. + +## ACP Isolation Strategy + +Do not use ACP extraction as a prerequisite. + +Instead: + +1. build `tui_gateway` directly around `AIAgent` +2. keep `acp_adapter/*` untouched during early migration +3. allow shared-runtime refactors later only if they reduce real maintenance cost + +Reasons: + +- ACP payloads are editor-shaped and add translation overhead +- ACP-first migration adds scope and time before the new TUI ships +- owner direction favors a fast clone path with gateway indirection, not transport unification work + +## Proposed Backend Split + +Extract the following concerns out of `cli.py`: + +1. `session controller` + A headless controller for create, resume, prompt, interrupt, cancel, and slash-command dispatch. +2. `event bridge` + Converts agent callbacks and tool progress into structured UI events. +3. `permission bridge` + Converts dangerous-command approval, sudo prompts, and clarify requests into request/response interactions. +4. `presentation adapters` + Optional formatting helpers for transcript items and tool previews, without owning terminal rendering. +5. `gateway adapter` + A thin request/event layer for `tui_gateway` over stdio JSON-RPC. + +The backend must stop depending on a terminal UI framework for control flow. + +## Shared Runtime Invariants + +The backend remains the source of truth for: + +- prompt assembly +- Honcho/memory synchronization +- tool dispatch +- approval policy +- slash-command execution +- session transitions + +The frontend should render protocol state, not own core agent behavior. + +In particular, the new TUI must not introduce UI-side blocking work into the turn path. Context, memory, Honcho prefetch, and similar backend concerns should stay behind the runtime boundary and preserve Hermes' existing caching and async-prefetch behavior. + +## Proposed Transport + +Start with stdio JSON-RPC. + +Reasons: + +- local CLI startup is simple +- process ownership is clear +- no port management + +WebSocket can be added later if Hermes wants: + +- remote terminal clients +- browser UI +- multiple concurrent viewers + +But it should not be the first transport. + +## Platform And Toolset Strategy + +The new UI should run Hermes in a dedicated `tui` platform mode. + +That mode should: + +- share most behavioral semantics with the current interactive CLI +- reuse the canonical slash-command registry rather than fork it +- preserve session continuity with other Hermes surfaces where the shared state model already supports it +- avoid editor-specific payload conventions in the TUI protocol + +Toolset strategy: + +- start from current interactive CLI capabilities +- only introduce a dedicated `hermes-tui` toolset if the transport boundary proves it is needed +- keep transport constraints out of tool business logic as much as possible + +## Protocol Shape + +The TUI protocol should be explicit and event-driven. + +Core requests: + +- `session.create` +- `session.resume` +- `session.list` +- `session.interrupt` +- `session.cancel` +- `session.set_cwd` +- `prompt.submit` +- `command.run` +- `approval.respond` +- `sudo.respond` +- `clarify.respond` + +Core events: + +- `session.state` +- `message.start` +- `message.delta` +- `message.complete` +- `thinking.delta` +- `tool.started` +- `tool.progress` +- `tool.completed` +- `approval.requested` +- `sudo.requested` +- `clarify.requested` +- `error` + +Design rule: every user-visible interactive state in the new TUI must come from protocol state, not local UI guesswork. + +## Ink Frontend Scope + +The first `Ink` frontend only needs a narrow set of surfaces: + +- transcript view +- input composer +- status/footer bar +- slash-command picker/help +- approval modal +- sudo prompt +- clarify prompt +- tool activity cards + +Do not start with: + +- mouse-heavy interaction +- custom selection model +- custom renderer internals +- Claude-style terminal instrumentation + +Those can come later if real gaps appear. + +## Migration Phases + +## Phase 1: Headless Runtime Extraction + +Goal: make Hermes usable without `prompt_toolkit`. + +Work: + +- introduce a backend session/controller module +- move PT-specific queues and rendering concerns out of agent flow +- replace direct CLI callback assumptions with abstract request/response hooks +- isolate slash-command execution from the PT shell +- introduce a `platform="tui"` runtime path without forking core agent logic + +Exit criteria: + +- a non-PT backend can run a prompt, stream progress, request approval, and return a final response + +## Phase 2: Local UI Gateway + +Goal: expose the backend over stdio JSON-RPC. + +Work: + +- create a `ui_gateway` package or equivalent module group +- model session lifecycle and event streaming +- implement cancel/interrupt behavior +- adapt terminal approval and sudo flow into transport messages +- keep config, profile, and session storage identical to existing Hermes surfaces + +Exit criteria: + +- a minimal client can drive a full Hermes session over stdio without importing `cli.py` + +## Phase 3: Ink MVP + +Goal: ship a working Hermes TUI without `prompt_toolkit`. + +Work: + +- create a Node/TS package for the TUI +- connect to the Python gateway +- render transcript + composer + status +- support approvals, clarify prompts, and slash commands +- preserve interrupt-and-redirect behavior for active runs + +Exit criteria: + +- Hermes can be used end-to-end from the new TUI for normal chat and tool use + +## Phase 4: Feature Parity + +Goal: close the biggest regressions from the legacy CLI. + +Work: + +- port session picker/resume UX +- port tool previews and long-running command status +- port config-aware commands +- port voice or explicitly defer it behind a non-blocking boundary + +Exit criteria: + +- daily-driver workflows no longer require the PT CLI + +## Phase 5: Cutover And Deletion + +Goal: make the new TUI the default interactive path. + +Work: + +- switch `hermes` interactive startup to the Ink client +- keep legacy PT path only behind a temporary fallback flag if needed +- delete PT-specific code after a short stabilization window + +Exit criteria: + +- `prompt_toolkit` is no longer part of the main interactive CLI + +## File-Level Refactor Targets + +Initial hot spots: + +- `cli.py` +- `tools/terminal_tool.py` +- `model_tools.py` +- `run_agent.py` +- `hermes_cli/commands.py` + +Expected pattern: + +- avoid importing PT code into the new backend path +- move any UI-specific formatting behind protocol events or thin adapters + +## First Implementation Slices (PR Plan) + +Keep early PRs narrow and mergeable. Do not start with a large branch that rewrites `cli.py` end-to-end. + +1. `PR-1: headless session controller` + - add a transport-neutral controller around `AIAgent` for create/resume/prompt/interrupt/cancel + - no UI, no PT dependencies +2. `PR-2: local ui gateway (stdio json-rpc)` + - add `ui_gateway` process entry + - implement protocol requests/events for one full prompt cycle +3. `PR-3: ink shell bootstrap` + - add Node/TS package with gateway client + - render transcript + composer + status + streaming deltas +4. `PR-4: interactive controls parity` + - approvals, sudo, clarify flows + - interrupt-and-redirect and command routing +5. `PR-5: startup switch + fallback flag` + - add explicit opt-in startup flag for Ink path (`HERMES_EXPERIMENTAL_TUI=1` or equivalent) + - add CLI/config opt-in controls and `/tui` command entrypoint in legacy CLI + - keep PT path behind a temporary env/flag gate during stabilization +6. `PR-6: parity hardening and PT deletion` + - close remaining UX gaps from legacy CLI + - remove PT path after stability window + +## Concrete File Plan + +Use fixed locations so contributors do not invent parallel structures. + +`PR-1` files: + +- add `tui_gateway/controller.py` +- add `tui_gateway/session_state.py` +- add `tui_gateway/events.py` +- update `run_agent.py` only where callback wiring is needed +- update `cli.py` only to call controller entry points in compatibility mode + +`PR-2` files: + +- add `tui_gateway/protocol.py` +- add `tui_gateway/server.py` +- add `tui_gateway/entry.py` +- add `tui_gateway/__init__.py` +- add `tests/tui_gateway/test_protocol.py` +- add `tests/tui_gateway/test_server_flow.py` + +`PR-3` files: + +- add `ui-tui/package.json` +- add `ui-tui/src/main.tsx` +- add `ui-tui/src/gatewayClient.ts` +- add `ui-tui/src/state/store.ts` +- add `ui-tui/src/components/Transcript.tsx` +- add `ui-tui/src/components/Composer.tsx` +- add `ui-tui/src/components/StatusBar.tsx` + +`PR-4` files: + +- add `ui-tui/src/components/ApprovalModal.tsx` +- add `ui-tui/src/components/SudoPrompt.tsx` +- add `ui-tui/src/components/ClarifyPrompt.tsx` +- update `tools/terminal_tool.py` to use gateway request/response adapters instead of PT-specific assumptions +- add `tests/tui_gateway/test_permissions_roundtrip.py` + +`PR-5` files: + +- update `hermes_cli/main.py` startup selection for `--tui` and env/config flags +- update `hermes_cli/commands.py` with `/tui` commands +- update `cli.py` command dispatch to launch/attach behavior +- add `tests/hermes_cli/test_tui_opt_in.py` + +`PR-6` files: + +- remove PT-only paths from `cli.py` once parity checks pass +- remove obsolete PT wiring helpers +- update docs and command help text + +If path names change, keep one module per role and avoid duplicate gateway implementations. + +## Protocol Envelope (v0) + +Use one JSON-RPC envelope shape for all gateway traffic. + +Request: + +```json +{ + "jsonrpc": "2.0", + "id": "req-123", + "method": "prompt.submit", + "params": { + "session_id": "sess-1", + "text": "hello" + } +} +``` + +Event notification: + +```json +{ + "jsonrpc": "2.0", + "method": "event", + "params": { + "type": "message.delta", + "session_id": "sess-1", + "payload": { + "text": "hi" + } + } +} +``` + +Error: + +```json +{ + "jsonrpc": "2.0", + "id": "req-123", + "error": { + "code": 4001, + "message": "session not found" + } +} +``` + +Protocol rules: + +- all event ordering is per-session FIFO +- ids are opaque strings +- unknown event types are ignored by clients and logged +- protocol version is pinned in `tui_gateway/protocol.py` + +## Acceptance Checks Per Phase + +Each phase should ship with explicit checks: + +- `runtime` + - prompt executes end-to-end without importing `prompt_toolkit` + - interrupt and cancel are deterministic +- `state continuity` + - same `HERMES_HOME`, `config.yaml`, `state.db`, and profile behavior as existing Hermes surfaces +- `commands` + - slash-command resolution uses shared registry (`hermes_cli/commands.py`) +- `permissions` + - dangerous command approval, sudo prompt, and clarify prompt all round-trip through protocol events +- `streaming` + - message/tool progress events stream incrementally; no UI-side polling loop for core turn output +- `opt-in controls` + - `--tui`, env flag, config toggle, and `/tui` commands all resolve to the same launch behavior + - failures fall back to PT mode with explicit error output + +## Test Commands Per PR + +`PR-1`: + +- `python -m pytest tests/tui_gateway/test_controller.py -q` + +`PR-2`: + +- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q` + +`PR-3`: + +- `cd ui-tui && npm run build` +- `cd ui-tui && npm run test` + +`PR-4`: + +- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q` +- `cd ui-tui && npm run test` + +`PR-5`: + +- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q` + +`PR-6`: + +- `python -m pytest tests/ -q` + +## Opt-In UX Surface + +Expose TUI opt-in through user-facing TUI language, not transport language. + +Entry points: + +- startup flag: `hermes --tui` +- env flag: `HERMES_EXPERIMENTAL_TUI=1` +- config toggle: `display.experimental_tui: true` +- slash command in legacy CLI: + - `/tui` (launch/attach) + - `/tui on` (persist opt-in) + - `/tui off` (disable auto-launch) + - `/tui status` (show mode + process/attach state) + +Behavior: + +- if `/tui` is called and the local TUI gateway is not running, start it and attach +- if already running, attach/reuse session +- on startup/attach failure, print clear error and stay in PT mode +- `/tui off` disables future auto-launch; it does not terminate active sessions unless requested + +## Rollout And Rollback + +Rollout should be staged: + +1. internal opt-in (`HERMES_EXPERIMENTAL_TUI=1` or equivalent) +2. external opt-in beta (still flag-gated, PT remains default) +3. default-on with PT fallback still available, only after acceptance checks are green +4. PT removal after a short stability window + +Rollback path must remain simple until PT deletion: + +- one switch to restore legacy interactive startup +- no data migration required between TUI and PT modes (shared state model) + +## Main Risks + +1. `cli.py` currently owns more state than it appears to. Extraction will uncover hidden coupling. +2. Approval and sudo flows are global/callback-driven today and need per-session protocol state. +3. Long-running tool output may currently assume terminal-local behavior that has to be normalized before transport. +4. Voice mode may carry PT assumptions and should be treated as optional during the first cut. +5. If the frontend demands behavior beyond stock `Ink`, the team may need to introduce custom terminal primitives later. + +## Recommendation + +Start with stock `Ink` and a direct `tui_gateway` over stdio JSON-RPC. + +Do not: + +- refactor `prompt_toolkit` forward +- route the terminal UI through the messaging gateway +- begin by vendoring Claude Code's renderer + +The shortest path to a good Hermes TUI is: + +1. extract headless backend control flow +2. expose it over stdio JSON-RPC +3. build the TUI in `Ink` +4. only customize deeper terminal behavior after real product pressure appears + +## Success Criteria + +This migration succeeds if Hermes can: + +- start an interactive session without `prompt_toolkit` +- stream assistant and tool activity live into an `Ink` UI +- handle approvals, clarify requests, sudo prompts, and interrupts cleanly +- preserve the existing Python agent/tool runtime +- preserve existing Hermes config, profile, and session continuity expectations +- preserve shared slash-command semantics instead of inventing a second command surface +- avoid adding new blocking UI-driven work into the prompt path +- make the legacy PT shell deletable rather than permanent diff --git a/tui_gateway/__init__.py b/tui_gateway/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py new file mode 100644 index 000000000..72a4537f4 --- /dev/null +++ b/tui_gateway/entry.py @@ -0,0 +1,36 @@ +import json +import sys + +from tui_gateway.server import handle_request, resolve_skin + + +def _write(obj: dict): + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + + +def main(): + _write({ + "jsonrpc": "2.0", + "method": "event", + "params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}}, + }) + + for raw in sys.stdin: + line = raw.strip() + if not line: + continue + + try: + req = json.loads(line) + except json.JSONDecodeError: + _write({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}) + continue + + resp = handle_request(req) + if resp is not None: + _write(resp) + + +if __name__ == "__main__": + main() diff --git a/tui_gateway/server.py b/tui_gateway/server.py new file mode 100644 index 000000000..5a1c35069 --- /dev/null +++ b/tui_gateway/server.py @@ -0,0 +1,351 @@ +import json +import os +import subprocess +import sys +import threading +import uuid +from pathlib import Path + +from hermes_constants import get_hermes_home +from hermes_cli.env_loader import load_hermes_dotenv + +_hermes_home = get_hermes_home() +load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env") + +_sessions: dict[str, dict] = {} +_methods: dict[str, callable] = {} +_clarify_pending: dict[str, threading.Event] = {} +_clarify_answers: dict[str, str] = {} + + +# ── Wire ───────────────────────────────────────────────────────────── + +def _emit(event_type: str, sid: str, payload: dict | None = None): + params = {"type": event_type, "session_id": sid} + if payload: + params["payload"] = payload + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n") + sys.stdout.flush() + + +def _ok(req_id, result: dict) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def _err(req_id, code: int, msg: str) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": msg}} + + +def method(name: str): + def dec(fn): + _methods[name] = fn + return fn + return dec + + +def handle_request(req: dict) -> dict | None: + fn = _methods.get(req.get("method", "")) + if not fn: + return _err(req.get("id"), -32601, f"unknown method: {req.get('method')}") + return fn(req.get("id"), req.get("params", {})) + + +# ── Helpers ────────────────────────────────────────────────────────── + +def resolve_skin() -> dict: + try: + import yaml + from hermes_cli.skin_engine import init_skin_from_config, get_active_skin + cfg_path = _hermes_home / "config.yaml" + cfg = {} + if cfg_path.exists(): + with open(cfg_path) as f: + cfg = yaml.safe_load(f) or {} + init_skin_from_config(cfg) + skin = get_active_skin() + return {"name": skin.name, "colors": skin.colors, "branding": skin.branding} + except Exception: + return {} + + +def _resolve_model() -> str: + env = os.environ.get("HERMES_MODEL", "") + if env: + return env + try: + import yaml + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path) as f: + m = (yaml.safe_load(f) or {}).get("model", "") + if isinstance(m, dict): + return m.get("default", "") + if isinstance(m, str): + return m + except Exception: + pass + return "anthropic/claude-sonnet-4" + + +def _get_usage(agent) -> dict: + ga = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) + return { + "input": ga("session_input_tokens", "session_prompt_tokens"), + "output": ga("session_output_tokens", "session_completion_tokens"), + "total": ga("session_total_tokens"), + "calls": ga("session_api_calls"), + } + + +def _collect_session_info(agent) -> dict: + info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}} + try: + from model_tools import get_toolset_for_tool + for t in getattr(agent, "tools", []) or []: + name = t["function"]["name"] + info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name) + except Exception: + pass + try: + from hermes_cli.banner import get_available_skills + info["skills"] = get_available_skills() + except Exception: + pass + return info + + +def _make_clarify_cb(sid: str): + def cb(question: str, choices: list | None) -> str: + rid = uuid.uuid4().hex[:8] + ev = threading.Event() + _clarify_pending[rid] = ev + _emit("clarify.request", sid, {"request_id": rid, "question": question, "choices": choices}) + ev.wait(timeout=300) + _clarify_pending.pop(rid, None) + return _clarify_answers.pop(rid, "") + return cb + + +def _register_approval_notify(sid: str, session_key: str): + try: + from tools.approval import register_gateway_notify + register_gateway_notify(session_key, lambda data: _emit("approval.request", sid, data)) + except Exception: + pass + + +# ── Methods ────────────────────────────────────────────────────────── + +@method("session.create") +def _(req_id, params: dict) -> dict: + sid = uuid.uuid4().hex[:8] + session_key = f"tui-{sid}" + + os.environ["HERMES_SESSION_KEY"] = session_key + os.environ["HERMES_INTERACTIVE"] = "1" + + try: + from run_agent import AIAgent + agent = AIAgent( + model=_resolve_model(), + quiet_mode=True, + platform="tui", + tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}), + tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}), + tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}), + thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), + reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), + status_callback=lambda text: _emit("status.update", sid, {"text": text}), + clarify_callback=_make_clarify_cb(sid), + ) + _sessions[sid] = {"agent": agent, "session_key": session_key} + except Exception as e: + return _err(req_id, 5000, f"agent init failed: {e}") + + _register_approval_notify(sid, session_key) + + from tools.approval import load_permanent_allowlist + load_permanent_allowlist() + + _emit("session.info", sid, _collect_session_info(agent)) + return _ok(req_id, {"session_id": sid}) + + +@method("prompt.submit") +def _(req_id, params: dict) -> dict: + sid, text = params.get("session_id", ""), params.get("text", "") + session = _sessions.get(sid) + if not session: + return _err(req_id, 4001, "session not found") + + agent = session["agent"] + _emit("message.start", sid) + + def run(): + try: + result = agent.run_conversation( + text, + stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), + ) + + if isinstance(result, dict): + final = result.get("final_response", "") + status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" + _emit("message.complete", sid, { + "text": final or "", + "usage": _get_usage(agent), + "status": status, + }) + else: + _emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"}) + + except Exception as e: + _emit("error", sid, {"message": str(e)}) + + threading.Thread(target=run, daemon=True).start() + return _ok(req_id, {"status": "streaming"}) + + +@method("clarify.respond") +def _(req_id, params: dict) -> dict: + rid = params.get("request_id", "") + ev = _clarify_pending.get(rid) + if not ev: + return _err(req_id, 4003, "no pending clarify request") + _clarify_answers[rid] = params.get("answer", "") + ev.set() + return _ok(req_id, {"status": "ok"}) + + +@method("approval.respond") +def _(req_id, params: dict) -> dict: + sid = params.get("session_id", "") + choice = params.get("choice", "deny") + + session = _sessions.get(sid) + if not session: + return _err(req_id, 4001, "session not found") + + try: + from tools.approval import resolve_gateway_approval + n = resolve_gateway_approval(session["session_key"], choice, resolve_all=params.get("all", False)) + return _ok(req_id, {"resolved": n}) + except Exception as e: + return _err(req_id, 5004, str(e)) + + +@method("session.usage") +def _(req_id, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + if not session: + return _err(req_id, 4001, "session not found") + return _ok(req_id, _get_usage(session["agent"])) + + +@method("session.history") +def _(req_id, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + if not session: + return _err(req_id, 4001, "session not found") + history = getattr(session["agent"], "conversation_history", []) + return _ok(req_id, {"count": len(history)}) + + +@method("session.undo") +def _(req_id, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + if not session: + return _err(req_id, 4001, "session not found") + history = getattr(session["agent"], "conversation_history", []) + removed = 0 + while history and history[-1].get("role") in ("assistant", "tool"): + history.pop(); removed += 1 + if history and history[-1].get("role") == "user": + history.pop(); removed += 1 + return _ok(req_id, {"removed": removed}) + + +@method("session.compress") +def _(req_id, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + if not session: + return _err(req_id, 4001, "session not found") + agent = session["agent"] + try: + if hasattr(agent, "compress_context"): + agent.compress_context() + return _ok(req_id, {"status": "compressed", "usage": _get_usage(agent)}) + except Exception as e: + return _err(req_id, 5005, str(e)) + + +@method("config.set") +def _(req_id, params: dict) -> dict: + key, value = params.get("key", ""), params.get("value", "") + + if key == "model": + os.environ["HERMES_MODEL"] = value + return _ok(req_id, {"key": key, "value": value}) + + if key == "skin": + try: + import yaml + cfg_path = _hermes_home / "config.yaml" + cfg = {} + if cfg_path.exists(): + with open(cfg_path) as f: + cfg = yaml.safe_load(f) or {} + cfg["skin"] = value + with open(cfg_path, "w") as f: + yaml.safe_dump(cfg, f) + return _ok(req_id, {"key": key, "value": value}) + except Exception as e: + return _err(req_id, 5001, str(e)) + + return _err(req_id, 4002, f"unknown config key: {key}") + + +@method("session.interrupt") +def _(req_id, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + if not session: + return _err(req_id, 4001, "session not found") + + if hasattr(session["agent"], "interrupt"): + session["agent"].interrupt() + + for rid, ev in list(_clarify_pending.items()): + _clarify_answers[rid] = "" + ev.set() + + try: + from tools.approval import resolve_gateway_approval + resolve_gateway_approval(session["session_key"], "deny", resolve_all=True) + except Exception: + pass + + return _ok(req_id, {"status": "interrupted"}) + + +@method("shell.exec") +def _(req_id, params: dict) -> dict: + cmd = params.get("command", "") + if not cmd: + return _err(req_id, 4004, "empty command") + + try: + from tools.approval import detect_dangerous_command + is_dangerous, _, description = detect_dangerous_command(cmd) + if is_dangerous: + return _err(req_id, 4005, f"blocked: {description}. Use the agent for dangerous commands (it has approval flow).") + except ImportError: + pass + + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()) + return _ok(req_id, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) + except subprocess.TimeoutExpired: + return _err(req_id, 5002, "command timed out (30s)") + except Exception as e: + return _err(req_id, 5003, str(e)) diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json new file mode 100644 index 000000000..a5b78523c --- /dev/null +++ b/ui-tui/package-lock.json @@ -0,0 +1,1213 @@ +{ + "name": "hermes-tui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermes-tui", + "version": "0.0.1", + "dependencies": { + "ink": "^6.8.0", + "ink-text-input": "^6.0.0", + "react": "^19.2.4" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz", + "integrity": "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.5.tgz", + "integrity": "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.5.tgz", + "integrity": "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.5.tgz", + "integrity": "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.5.tgz", + "integrity": "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.5.tgz", + "integrity": "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.5.tgz", + "integrity": "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.5.tgz", + "integrity": "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.5.tgz", + "integrity": "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.5.tgz", + "integrity": "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.5.tgz", + "integrity": "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.5.tgz", + "integrity": "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.5.tgz", + "integrity": "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.5.tgz", + "integrity": "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.5.tgz", + "integrity": "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.5.tgz", + "integrity": "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.5.tgz", + "integrity": "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.5.tgz", + "integrity": "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.5.tgz", + "integrity": "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.5.tgz", + "integrity": "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.5.tgz", + "integrity": "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.5.tgz", + "integrity": "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.5.tgz", + "integrity": "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.5.tgz", + "integrity": "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.5.tgz", + "integrity": "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.5.tgz", + "integrity": "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz", + "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.5", + "@esbuild/android-arm": "0.27.5", + "@esbuild/android-arm64": "0.27.5", + "@esbuild/android-x64": "0.27.5", + "@esbuild/darwin-arm64": "0.27.5", + "@esbuild/darwin-x64": "0.27.5", + "@esbuild/freebsd-arm64": "0.27.5", + "@esbuild/freebsd-x64": "0.27.5", + "@esbuild/linux-arm": "0.27.5", + "@esbuild/linux-arm64": "0.27.5", + "@esbuild/linux-ia32": "0.27.5", + "@esbuild/linux-loong64": "0.27.5", + "@esbuild/linux-mips64el": "0.27.5", + "@esbuild/linux-ppc64": "0.27.5", + "@esbuild/linux-riscv64": "0.27.5", + "@esbuild/linux-s390x": "0.27.5", + "@esbuild/linux-x64": "0.27.5", + "@esbuild/netbsd-arm64": "0.27.5", + "@esbuild/netbsd-x64": "0.27.5", + "@esbuild/openbsd-arm64": "0.27.5", + "@esbuild/openbsd-x64": "0.27.5", + "@esbuild/openharmony-arm64": "0.27.5", + "@esbuild/sunos-x64": "0.27.5", + "@esbuild/win32-arm64": "0.27.5", + "@esbuild/win32-ia32": "0.27.5", + "@esbuild/win32-x64": "0.27.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", + "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.4", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^8.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.1", + "terminal-size": "^4.0.1", + "type-fest": "^5.4.1", + "widest-line": "^6.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + } + } +} diff --git a/ui-tui/package.json b/ui-tui/package.json new file mode 100644 index 000000000..52c400d42 --- /dev/null +++ b/ui-tui/package.json @@ -0,0 +1,23 @@ +{ + "name": "hermes-tui", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --watch src/main.tsx", + "start": "tsx src/main.tsx", + "build": "tsc", + "test": "echo 'no tests yet'" + }, + "dependencies": { + "ink": "^6.8.0", + "ink-text-input": "^6.0.0", + "react": "^19.2.4" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/ui-tui/src/altScreen.tsx b/ui-tui/src/altScreen.tsx new file mode 100644 index 000000000..f6c74888b --- /dev/null +++ b/ui-tui/src/altScreen.tsx @@ -0,0 +1,29 @@ +import { useEffect, type PropsWithChildren } from 'react' +import { Box, useStdout } from 'ink' + +const ENTER = '\x1b[?1049h\x1b[2J\x1b[H' +const LEAVE = '\x1b[?1049l' + +export function AltScreen({ children }: PropsWithChildren) { + const { stdout } = useStdout() + const rows = stdout?.rows ?? 24 + const cols = stdout?.columns ?? 80 + + useEffect(() => { + process.stdout.write(ENTER) + + const leave = () => process.stdout.write(LEAVE) + process.on('exit', leave) + + return () => { + leave() + process.off('exit', leave) + } + }, []) + + return ( + + {children} + + ) +} diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts new file mode 100644 index 000000000..3d52c9137 --- /dev/null +++ b/ui-tui/src/banner.ts @@ -0,0 +1,43 @@ +import type { ThemeColors } from './theme.js' + +type Line = [string, string] + +const LOGO_ART = [ + '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', + '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', + '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', + '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', + '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', + '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ', +] + +const CADUCEUS_ART = [ + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀', + '⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀', + '⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', +] + +const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const +const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const + +function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] { + const palette = [c.gold, c.amber, c.bronze, c.dim] + return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text]) +} + +export const LOGO_WIDTH = 98 + +export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c) +export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts new file mode 100644 index 000000000..95ae06b1c --- /dev/null +++ b/ui-tui/src/gatewayClient.ts @@ -0,0 +1,72 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createInterface } from 'node:readline' +import { EventEmitter } from 'node:events' +import { resolve } from 'node:path' + +export interface GatewayEvent { + type: string + session_id?: string + payload?: Record +} + +interface Pending { + resolve: (v: unknown) => void + reject: (e: Error) => void +} + +export class GatewayClient extends EventEmitter { + private proc: ChildProcess | null = null + private reqId = 0 + private pending = new Map() + + start() { + const root = resolve(import.meta.dirname, '../../') + + this.proc = spawn( + process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), + ['-m', 'tui_gateway.entry'], + { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] }, + ) + + createInterface({ input: this.proc.stdout! }).on('line', (raw) => { + try { this.dispatch(JSON.parse(raw)) } catch {} + }) + + this.proc.on('exit', (code) => this.emit('exit', code)) + } + + private dispatch(msg: Record) { + const id = msg.id as string | undefined + const p = id ? this.pending.get(id) : undefined + + if (p) { + this.pending.delete(id!) + msg.error + ? p.reject(new Error((msg.error as any).message)) + : p.resolve(msg.result) + return + } + + if (msg.method === 'event') + this.emit('event', msg.params as GatewayEvent) + } + + request(method: string, params: Record = {}): Promise { + const id = `r${++this.reqId}` + + this.proc!.stdin!.write( + JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', + ) + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + + setTimeout(() => { + if (this.pending.delete(id)) + reject(new Error(`timeout: ${method}`)) + }, 30_000) + }) + } + + kill() { this.proc?.kill() } +} diff --git a/ui-tui/src/main.js b/ui-tui/src/main.js new file mode 100644 index 000000000..892853bc9 --- /dev/null +++ b/ui-tui/src/main.js @@ -0,0 +1,46 @@ +"use strict"; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +var _a; +Object.defineProperty(exports, "__esModule", { value: true }); +var react_1 = require("react"); +var ink_1 = require("ink"); +var ink_text_input_1 = require("ink-text-input"); +function App() { + var _a = (0, react_1.useState)(''), input = _a[0], setInput = _a[1]; + var _b = (0, react_1.useState)([]), messages = _b[0], setMessages = _b[1]; + var handleSubmit = function (value) { + if (!value.trim()) + return; + setMessages(function (prev) { return __spreadArray(__spreadArray([], prev, true), ["> ".concat(value), "[echo] ".concat(value)], false); }); + setInput(''); + }; + return ( + + hermes + (ink proof-of-concept) + + + + {messages.map(function (msg, i) { return ({msg}); })} + + + + {'> '} + + + ); +} +var isTTY = (_a = process.stdin.isTTY) !== null && _a !== void 0 ? _a : false; +if (!isTTY) { + console.log('hermes-tui: ink loaded, no TTY attached (run in a real terminal)'); + process.exit(0); +} +(0, ink_1.render)(); diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx new file mode 100644 index 000000000..fa9d1bee4 --- /dev/null +++ b/ui-tui/src/main.tsx @@ -0,0 +1,1073 @@ +'use strict' + +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { render, Box, Text, useApp, useStdout, useInput } from 'ink' +import TextInput from 'ink-text-input' + +import { GatewayClient, type GatewayEvent } from './gatewayClient.js' +import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' +import { logo, caduceus, LOGO_WIDTH } from './banner.js' +import { AltScreen } from './altScreen.js' + + +type Role = 'user' | 'assistant' | 'system' | 'tool' + +interface Msg { role: Role; text: string } +interface SessionInfo { model: string; tools: Record; skills: Record } +interface ActiveTool { id: string; name: string } +interface ClarifyReq { requestId: string; question: string; choices: string[] | null } +interface ApprovalReq { command: string; description: string } +interface Usage { input: number; output: number; total: number; calls: number } + +const ZERO: Usage = { input: 0, output: 0, total: 0, calls: 0 } +const MAX_CTX = 128_000 + +const COMMANDS: [string, string][] = [ + ['/help', 'commands & hotkeys'], + ['/model', 'switch model'], + ['/skin', 'change theme'], + ['/clear', 'reset chat'], + ['/new', 'new session'], + ['/undo', 'drop last exchange'], + ['/retry', 'resend last message'], + ['/compact', 'toggle compact [focus]'], + ['/cost', 'token usage stats'], + ['/copy', 'copy last response'], + ['/context', 'context window info'], + ['/compress', 'compress context'], + ['/skills', 'list skills'], + ['/config', 'show config'], + ['/status', 'session info'], + ['/quit', 'exit hermes'], +] + +const HOTKEYS: [string, string][] = [ + ['Ctrl+C', 'interrupt / clear / exit'], + ['Ctrl+D', 'exit'], + ['Ctrl+L', 'clear screen'], + ['↑/↓', 'queue edit (if queued) / input history'], + ['PgUp/PgDn','scroll messages'], + ['Ctrl+J', 'newline in input'], + ['Esc', 'clear input'], + ['\\+Enter', 'multi-line continuation'], + ['!cmd', 'run shell command'], + ['{!cmd}', 'interpolate shell output inline'], +] + +const PLACEHOLDERS = [ + 'Ask me anything…', 'Try "explain this codebase"', 'Try "write a test for…"', + 'Try "refactor the auth module"', 'Try "/help" for commands', + 'Try "fix the lint errors"', 'Try "how does the config loader work?"', +] + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +const FACES = [ + '(。•́︿•̀。)', '(◔_◔)', '(¬‿¬)', '( •_•)>⌐■-■', '(⌐■_■)', + '(´・_・`)', '◉_◉', '(°ロ°)', '( ˘⌣˘)♡', 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', '(⊙_⊙)', '(¬_¬)', '( ͡° ͜ʖ ͡°)', 'ಠ_ಠ', +] + +const VERBS = [ + 'pondering', 'contemplating', 'musing', 'cogitating', 'ruminating', + 'deliberating', 'mulling', 'reflecting', 'processing', 'reasoning', + 'analyzing', 'computing', 'synthesizing', 'formulating', 'brainstorming', +] + +const TOOL_VERBS: Record = { + read_file: '📖 reading', write_file: '✏️ writing', search_code: '🔍 searching', + run_command: '⚙️ running', execute_code: '⚡ executing', list_files: '📂 listing', + web_search: '🌐 searching', create_file: '📝 creating', delete_file: '🗑️ deleting', + memory: '🧠 remembering', clarify: '❓ asking', delegate_task: '🤖 delegating', + browser: '🌐 browsing', terminal: '💻 terminal', patch: '🩹 patching', + search_files: '🔍 searching', image_generate: '🎨 generating', +} + +const ROLE: Record [string, string, string]> = { + user: t => [t.brand.prompt + ' ', t.color.label, t.color.label], + assistant: t => [t.brand.tool + ' ', t.color.bronze, t.color.cornsilk], + system: t => ['! ', t.color.error, t.color.error], + tool: t => ['⚡ ', t.color.dim, t.color.dim], +} + +const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! +const fmtK = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}` +const flat = (r: Record) => Object.values(r).flat() +const estimateRows = (text: string, w: number) => + text.split('\n').reduce((s, l) => s + Math.max(1, Math.ceil(Math.max(1, l.length) / w)), 0) +const compactPreview = (s: string, max: number) => { + const one = s.replace(/\s+/g, ' ').trim() + if (!one) return '' + return one.length > max ? one.slice(0, max - 1) + '…' : one +} +const PLACEHOLDER = pick(PLACEHOLDERS) + + +// ── Components ────────────────────────────────────────────────────── + +function ArtLines({ lines }: { lines: [string, string][] }) { + return <>{lines.map(([c, text], i) => {text})} +} + +function Banner({ t }: { t: Theme }) { + const cols = useStdout().stdout?.columns ?? 80 + + return ( + + {cols >= LOGO_WIDTH + ? + : {t.brand.icon} NOUS HERMES} + + + + + {t.brand.icon} Nous Research + · Messenger of the Digital Gods + + + ) +} + +function truncLine(pfx: string, items: string[], max: number): string { + let line = '' + for (const item of items.sort()) { + const next = line ? `${line}, ${item}` : item + if (pfx.length + next.length > max) + return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + line = next + } + return line +} + +function SessionPanel({ t, info }: { t: Theme; info: SessionInfo }) { + const cols = useStdout().stdout?.columns ?? 100 + const wide = cols >= 90 + const w = wide ? cols - 46 : cols - 10 + const strip = (s: string) => s.endsWith('_tools') ? s.slice(0, -6) : s + + const section = (title: string, data: Record, max = 8) => { + const entries = Object.entries(data).sort() + const shown = entries.slice(0, max) + const overflow = entries.length - max + + return ( + + Available {title} + + {shown.map(([k, vs]) => ( + + {strip(k)}: + {truncLine(strip(k) + ': ', vs, w)} + + ))} + + {overflow > 0 && ( + (and {overflow} more…) + )} + + ) + } + + return ( + + + {wide && ( + + + + Nous Research + + )} + + + {t.brand.icon} {t.brand.name} + + {section('Tools', info.tools)} + {section('Skills', info.skills)} + + + + + {flat(info.tools).length} tools + {' · '}{flat(info.skills).length} skills + {' · '}/help for commands + + + + {info.model.split('/').pop()} + {' · '}Ctrl+C to interrupt + + + + ) +} + +function CommandPalette({ t, filter }: { t: Theme; filter: string }) { + const m = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) + if (!m.length) return null + + return ( + + {m.map(([cmd, desc]) => ( + + {cmd} + — {desc} + + ))} + + ) +} + +function Thinking({ t, tools, reasoning }: { t: Theme; tools: ActiveTool[]; reasoning: string }) { + const [frame, setFrame] = useState(0) + const [verb] = useState(() => pick(VERBS)) + const [face] = useState(() => pick(FACES)) + + useEffect(() => { + const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80) + return () => clearInterval(id) + }, []) + + return ( + + {tools.length + ? tools.map(tool => ( + + {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… + + )) + : {SPINNER[frame]} {face} {verb}…} + + {reasoning && ( + + {' 💭 '}{reasoning.slice(-120).replace(/\n/g, ' ')} + + )} + + ) +} + + +// ── Interactive Prompts ───────────────────────────────────────────── + +function ClarifyPrompt({ t, req, onAnswer }: { t: Theme; req: ClarifyReq; onAnswer: (s: string) => void }) { + const [sel, setSel] = useState(0) + const [custom, setCustom] = useState('') + const [typing, setTyping] = useState(false) + const choices = req.choices ?? [] + + useInput((ch, key) => { + if (typing) return + if (key.upArrow && sel > 0) setSel(s => s - 1) + if (key.downArrow && sel < choices.length) setSel(s => s + 1) + if (key.return) { + if (sel === choices.length) setTyping(true) + else if (choices[sel]) onAnswer(choices[sel]!) + } + const n = parseInt(ch) + if (n >= 1 && n <= choices.length) onAnswer(choices[n - 1]!) + }) + + if (typing || !choices.length) + return ( + + ❓ {req.question} + + {'> '} + + + + ) + + const row = (i: number, label: string) => ( + + {sel === i ? '▸ ' : ' '} + {i + 1}. {label} + + ) + + return ( + + ❓ {req.question} + {choices.map((c, i) => row(i, c))} + {row(choices.length, 'Other (type your answer)')} + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick + + ) +} + +function ApprovalPrompt({ t, req, onChoice }: { t: Theme; req: ApprovalReq; onChoice: (s: string) => void }) { + const [sel, setSel] = useState(3) + const opts = ['once', 'session', 'always', 'deny'] as const + + useInput((ch, key) => { + if (key.upArrow && sel > 0) setSel(s => s - 1) + if (key.downArrow && sel < 3) setSel(s => s + 1) + if (key.return) onChoice(opts[sel]!) + if (ch === 'o') onChoice('once') + if (ch === 's') onChoice('session') + if (ch === 'a') onChoice('always') + if (ch === 'd' || key.escape) onChoice('deny') + }) + + return ( + + ⚠️ DANGEROUS COMMAND: {req.description} + {req.command} + + {opts.map((o, i) => ( + + {sel === i ? '▸ ' : ' '} + + [{o[0]}] {o === 'once' ? 'Allow once' : o === 'session' ? 'Allow this session' : o === 'always' ? 'Always allow' : 'Deny'} + + + ))} + ↑/↓ select · Enter confirm · o/s/a/d quick pick + + ) +} + + +// ── Markdown ──────────────────────────────────────────────────────── + +function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) { + const lines = text.split('\n') + const nodes: React.ReactNode[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i]! + const k = nodes.length + + if (compact && !line.trim()) { i++; continue } + + if (line.startsWith('```')) { + const lang = line.slice(3).trim() + const block: string[] = [] + for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) + block.push(lines[i]!) + i++ + nodes.push( + + {lang && {'─ ' + lang}} + {block.map((l, j) => {l})} + , + ) + continue + } + + const hm = line.match(/^#{1,3}\s+(.*)/) + if (hm) { nodes.push({hm[1]}); i++; continue } + + const bm = line.match(/^\s*[-*]\s(.*)/) + if (bm) { nodes.push(); i++; continue } + + const nm = line.match(/^\s*(\d+)\.\s(.*)/) + if (nm) { nodes.push( {nm[1]}. ); i++; continue } + + nodes.push() + i++ + } + + return {nodes} +} + +function MdInline({ t, text }: { t: Theme; text: string }) { + const parts: React.ReactNode[] = [] + const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g + let last = 0 + let m: RegExpExecArray | null + + while ((m = re.exec(text)) !== null) { + if (m.index > last) + parts.push({text.slice(last, m.index)}) + + if (m[2] && m[3]) { + parts.push({m[2]}) + } else if (m[4]) { + parts.push({m[4]}) + } else if (m[5]) { + parts.push({m[5]}) + } else if (m[6]) { + parts.push({m[6]}) + } else if (m[7]) { + parts.push({m[7]}) + } + + last = m.index + m[0].length + } + + if (last < text.length) + parts.push({text.slice(last)}) + + return {parts.length ? parts : {text}} +} + + +// ── Message ───────────────────────────────────────────────────────── + +function MessageLine({ t, msg, compact }: { t: Theme; msg: Msg; compact?: boolean }) { + const [, pc, tc] = ROLE[msg.role](t) + const glyph = msg.role === 'user' ? t.brand.prompt + : msg.role === 'assistant' ? t.brand.tool + : msg.role === 'tool' ? '⚡' : '!' + + return ( + + {glyph} + + {msg.role === 'assistant' + ? + : {msg.text}} + + ) +} + + +// ── App ───────────────────────────────────────────────────────────── + +function App({ gw }: { gw: GatewayClient }) { + const { exit } = useApp() + const { stdout } = useStdout() + const cols = stdout?.columns ?? 80 + + const [input, setInput] = useState('') + const [inputBuf, setInputBuf] = useState([]) + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('summoning hermes…') + const [sid, setSid] = useState(null) + const [theme, setTheme] = useState(DEFAULT_THEME) + const [info, setInfo] = useState(null) + const [thinking, setThinking] = useState(false) + const [tools, setTools] = useState([]) + const [busy, setBusy] = useState(false) + const [compact, setCompact] = useState(false) + const [usage, setUsage] = useState(ZERO) + const [clarify, setClarify] = useState(null) + const [approval, setApproval] = useState(null) + const [reasoning, setReasoning] = useState('') + const [lastUserMsg, setLastUserMsg] = useState('') + const [queueEditIdx, setQueueEditIdx] = useState(null) + const [scrollOffset, setScrollOffset] = useState(0) + + const buf = useRef('') + const stickyRef = useRef(true) + const queueRef = useRef([]) + const historyRef = useRef([]) + const historyDraftRef = useRef('') + const [historyIdx, setHistoryIdx] = useState(null) + const queueEditIdxRef = useRef(null) + const [queuedDisplay, setQueuedDisplay] = useState([]) + const lastEmptySubmitAt = useRef(0) + const empty = !messages.length + + const setQueueEdit = (idx: number | null) => { + queueEditIdxRef.current = idx + setQueueEditIdx(idx) + } + + const enqueue = (text: string) => { + queueRef.current.push(text) + setQueuedDisplay([...queueRef.current]) + } + + const pushHistory = (text: string) => { + const t = text.trim() + if (!t) return + const h = historyRef.current + if (h.at(-1) !== t) h.push(t) + } + + const replaceQueued = (idx: number, text: string) => { + if (idx < 0 || idx >= queueRef.current.length) return + queueRef.current[idx] = text + setQueuedDisplay([...queueRef.current]) + } + + const removeQueued = (idx: number) => { + if (idx < 0 || idx >= queueRef.current.length) return + queueRef.current = queueRef.current.filter((_, i) => i !== idx) + setQueuedDisplay([...queueRef.current]) + } + + const dequeue = () => { + const [next, ...rest] = queueRef.current + queueRef.current = rest + setQueuedDisplay([...rest]) + return next + } + + useEffect(() => { if (stickyRef.current) setScrollOffset(0) }, [messages.length]) + + const termRows = stdout?.rows ?? 24 + const chromeRows = 2 + (empty ? 0 : 2) + (thinking ? 2 : 0) + 2 + const msgBudget = Math.max(3, termRows - chromeRows) + + const visibleSlice = useMemo(() => { + if (!messages.length) return { start: 0, end: 0, above: 0 } + const end = Math.max(0, messages.length - scrollOffset) + const w = Math.max(20, cols - 5) + let budget = msgBudget + let start = end + for (let i = end - 1; i >= 0 && budget > 0; i--) { + const margin = messages[i]!.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 + budget -= margin + estimateRows(messages[i]!.text, w) + if (budget >= 0) start = i + } + return { start, end, above: start } + }, [messages, scrollOffset, msgBudget, cols]) + + const scrollUp = (n: number) => { + setScrollOffset(prev => Math.min(Math.max(0, messages.length - 1), prev + n)) + stickyRef.current = false + } + const scrollDown = (n: number) => { + setScrollOffset(prev => { const next = Math.max(0, prev - n); if (next === 0) stickyRef.current = true; return next }) + } + const scrollBottom = () => { setScrollOffset(0); stickyRef.current = true } + + const sys = useCallback((text: string) => setMessages(p => [...p, { role: 'system' as const, text }]), []) + const idle = () => { setThinking(false); setTools([]); setBusy(false); setClarify(null); setApproval(null); setReasoning('') } + const die = () => { gw.kill(); exit() } + const clearIn = () => { setInput(''); setInputBuf([]); setQueueEdit(null); setHistoryIdx(null); historyDraftRef.current = '' } + const blocked = !!(clarify || approval) + + // ── Hotkeys ─────────────────────────────────────────────────────── + + useInput((ch, key) => { + if (blocked) { + if (key.ctrl && ch === 'c' && approval) { + gw.request('approval.respond', { session_id: sid, choice: 'deny' }).catch(() => {}) + setApproval(null); sys('denied') + } + return + } + + if (key.pageUp) { scrollUp(5); return } + if (key.pageDown) { scrollDown(5); return } + + if (key.upArrow && !inputBuf.length) { + if (queueRef.current.length) { + const len = queueRef.current.length + const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % len + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') + return + } + + const h = historyRef.current + if (!h.length) return + const idx = historyIdx === null ? h.length - 1 : Math.max(0, historyIdx - 1) + if (historyIdx === null) historyDraftRef.current = input + setHistoryIdx(idx) + setQueueEdit(null) + setInput(h[idx] ?? '') + return + } + + if (key.downArrow && !inputBuf.length) { + if (queueRef.current.length) { + const len = queueRef.current.length + const idx = queueEditIdx === null ? len - 1 : (queueEditIdx - 1 + len) % len + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') + return + } + + if (historyIdx === null) return + const h = historyRef.current + const next = historyIdx + 1 + if (next >= h.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(h[next] ?? '') + } + return + } + + if (key.ctrl && ch === 'c') { + if (busy && sid) { + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + idle(); setStatus('interrupted'); sys('interrupted by user') + setTimeout(() => setStatus('ready'), 1500) + } else if (input || inputBuf.length) { + clearIn() + } else { + die() + } + return + } + + if (key.ctrl && ch === 'd') die() + if (key.ctrl && ch === 'l') setMessages([]) + if (key.escape) clearIn() + }) + + // ── Gateway events ──────────────────────────────────────────────── + + const onEvent = useCallback((ev: GatewayEvent) => { + const p = ev.payload as any + + switch (ev.type) { + case 'gateway.ready': + if (p?.skin) setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) + setStatus('forging session…') + gw.request('session.create') + .then((r: any) => { setSid(r.session_id); setStatus('ready') }) + .catch((e: Error) => setStatus(`error: ${e.message}`)) + break + + case 'session.info': setInfo(p as SessionInfo); break + case 'thinking.delta': break + case 'message.start': setThinking(true); setBusy(true); setReasoning(''); setStatus('thinking…'); break + case 'status.update': if (p?.text) setStatus(p.text); break + + case 'reasoning.delta': + if (p?.text) setReasoning(prev => prev + p.text) + break + + case 'tool.generating': + if (p?.name) setStatus(`preparing ${p.name}…`) + break + + case 'tool.progress': + if (p?.preview) setMessages(prev => { + if (prev.at(-1)?.role === 'tool') return [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + return [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + }) + break + + case 'tool.start': + setTools(prev => [...prev, { id: p.tool_id, name: p.name }]) + setStatus(`running ${p.name}…`) + setMessages(prev => [...prev, { role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}…` }]) + break + + case 'tool.complete': + setTools(prev => prev.filter(t => t.id !== p.tool_id)) + break + + case 'clarify.request': + setClarify({ requestId: p.request_id, question: p.question, choices: p.choices }) + setStatus('waiting for input…') + break + + case 'approval.request': + setApproval({ command: p.command, description: p.description }) + setStatus('approval needed') + break + + case 'message.delta': + if (!p?.text) break + buf.current += p.text + setThinking(false); setTools([]); setReasoning('') + setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) + break + + case 'message.complete': + idle() + setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart())) + buf.current = ''; setStatus('ready') + if (p?.usage) setUsage(p.usage) + if (p?.status === 'interrupted') sys('response interrupted') + if (queueEditIdxRef.current !== null) break + + // drain queued message + const next = dequeue() + if (next) { + setLastUserMsg(next) + setMessages(prev => [...prev, { role: 'user' as const, text: next }]) + setStatus('thinking…'); setBusy(true); buf.current = '' + gw.request('prompt.submit', { session_id: ev.session_id, text: next }) + .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) }) + } + break + + case 'error': + sys(`error: ${p?.message}`) + idle(); setStatus('ready') + break + } + }, [gw, sys]) + + useEffect(() => { + gw.on('event', onEvent) + gw.on('exit', () => { setStatus('gateway exited'); exit() }) + return () => { gw.off('event', onEvent) } + }, [gw, exit, onEvent]) + + // ── Slash commands ──────────────────────────────────────────────── + + const slash = useCallback((cmd: string): boolean => { + const [name, ...rest] = cmd.slice(1).split(/\s+/) + const arg = rest.join(' ') + + switch (name) { + case 'help': + sys([ + ' Commands:', + ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`), + '', ' Hotkeys:', + ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`), + ].join('\n')) + return true + + case 'clear': setMessages([]); return true + case 'quit': case 'exit': die(); return true + + case 'new': + setStatus('forging session…') + gw.request('session.create') + .then((r: any) => { + setSid(r.session_id); setMessages([]); setUsage(ZERO) + setStatus('ready'); sys('new session started') + }) + .catch((e: Error) => setStatus(`error: ${e.message}`)) + return true + + case 'undo': + if (!sid) return true + gw.request('session.undo', { session_id: sid }) + .then((r: any) => { + if (r.removed > 0) { + setMessages(p => { const q = [...p]; while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop(); if (q.at(-1)?.role === 'user') q.pop(); return q }) + sys(`undid ${r.removed} messages`) + } else sys('nothing to undo') + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true + + case 'retry': + if (!lastUserMsg) { sys('nothing to retry'); return true } + setMessages(p => { const q = [...p]; while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop(); return q }) + setMessages(p => [...p, { role: 'user', text: lastUserMsg }]) + setStatus('thinking…'); setBusy(true); buf.current = '' + gw.request('prompt.submit', { session_id: sid, text: lastUserMsg }) + .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) }) + return true + + case 'compact': + if (arg) { setCompact(true); sys(`compact on, focus: ${arg}`) } + else { setCompact(c => !c); sys(`compact ${compact ? 'off' : 'on'}`) } + return true + + case 'compress': + if (!sid) return true + gw.request('session.compress', { session_id: sid }) + .then((r: any) => { sys('context compressed'); if (r.usage) setUsage(r.usage) }) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true + + case 'cost': case 'usage': + sys(`in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}`) + return true + + case 'copy': { + const msgs = messages.filter(m => m.role === 'assistant') + const target = msgs[arg ? Math.min(parseInt(arg), msgs.length) - 1 : msgs.length - 1] + if (!target) { sys('nothing to copy'); return true } + process.stdout.write(`\x1b]52;c;${Buffer.from(target.text).toString('base64')}\x07`) + sys('copied to clipboard') + return true + } + + case 'context': { + const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100)) + const filled = Math.round((pct / 100) * 30) + sys(`context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(filled)}${'░'.repeat(30 - filled)}] ${pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗'}`) + return true + } + + case 'config': + sys(`model: ${info?.model ?? '?'} session: ${sid ?? 'none'} compact: ${compact}\ntools: ${flat(info?.tools ?? {}).length} skills: ${flat(info?.skills ?? {}).length}`) + return true + + case 'status': + sys(`session: ${sid ?? 'none'} status: ${status} tokens: ${fmtK(usage.input)}↑ ${fmtK(usage.output)}↓ (${usage.calls} calls)`) + return true + + case 'skills': + if (!info?.skills || !Object.keys(info.skills).length) { sys('no skills loaded'); return true } + sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n')) + return true + + case 'model': + if (!arg) { sys('usage: /model '); return true } + gw.request('config.set', { key: 'model', value: arg }) + .then(() => sys(`model → ${arg}`)) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true + + case 'skin': + if (!arg) { sys('usage: /skin '); return true } + gw.request('config.set', { key: 'skin', value: arg }) + .then(() => sys(`skin → ${arg} (restart to apply)`)) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true + + default: return false + } + }, [gw, sid, status, sys, compact, info, usage, messages, lastUserMsg]) + + // ── Submit ──────────────────────────────────────────────────────── + + const submit = useCallback((value: string) => { + if (!value.trim() && !inputBuf.length) { + const now = Date.now() + const dbl = now - lastEmptySubmitAt.current < 450 + lastEmptySubmitAt.current = now + + if (dbl && queueRef.current.length) { + if (busy && sid) { + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + setStatus('interrupting…') + return + } + + const next = dequeue() + if (next && sid) { + setQueueEdit(null) + setLastUserMsg(next) + setMessages(p => [...p, { role: 'user', text: next }]) + setStatus('thinking…'); setBusy(true); buf.current = '' + gw.request('prompt.submit', { session_id: sid, text: next }) + .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) }) + } + } + return + } + lastEmptySubmitAt.current = 0 + + if (value.endsWith('\\')) { + setInputBuf(prev => [...prev, value.slice(0, -1)]) + setInput('') + return + } + + const full = [...inputBuf, value].join('\n') + setInputBuf([]) + if (!full.trim() || !sid) return + setInput('') + setHistoryIdx(null) + historyDraftRef.current = '' + + const editIdx = queueEditIdxRef.current + + // editing an already queued entry + if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { + replaceQueued(editIdx, full) + setQueueEdit(null) + return + } else if (editIdx !== null) { + setQueueEdit(null) + } + pushHistory(full) + + // queue if busy (slash commands still run immediately) + if (busy && !full.startsWith('/') && !full.startsWith('!')) { + enqueue(full) + return + } + + // !command → direct shell exec (entire line after !) + if (full.startsWith('!')) { + setMessages(p => [...p, { role: 'user', text: full }]) + setBusy(true); setStatus('running…') + gw.request('shell.exec', { command: full.slice(1).trim() }) + .then((r: any) => { + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + sys(out || `exit ${r.code}`) + if (r.code !== 0 && out) sys(`exit ${r.code}`) + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => { setStatus('ready'); setBusy(false) }) + return + } + + if (full.startsWith('/') && slash(full)) return + + // {!cmd} inline shell interpolation + if (/\{!.+?\}/.test(full)) { + setBusy(true); setStatus('interpolating…') + const re = /\{!(.+?)\}/g + const matches = [...full.matchAll(re)] + Promise.all(matches.map(m => + gw.request('shell.exec', { command: m[1]! }) + .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim().split('\n')[0] ?? '') + .catch(() => '(error)') + )).then(results => { + let text = full + for (let i = matches.length - 1; i >= 0; i--) + text = text.slice(0, matches[i]!.index!) + results[i] + text.slice(matches[i]!.index! + matches[i]![0].length) + setLastUserMsg(text) + setMessages(p => [...p, { role: 'user', text }]) + setStatus('thinking…'); buf.current = '' + gw.request('prompt.submit', { session_id: sid, text }) + .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) }) + }) + return + } + + setLastUserMsg(full) + setMessages(p => [...p, { role: 'user', text: full }]) + scrollBottom() + setStatus('thinking…'); setBusy(true); buf.current = '' + gw.request('prompt.submit', { session_id: sid, text: full }) + .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) }) + }, [gw, sid, slash, sys, inputBuf, busy]) + + // ── Render ──────────────────────────────────────────────────────── + + const statusColor = status === 'ready' ? theme.color.ok + : status.startsWith('error') ? theme.color.error + : status === 'interrupted' ? theme.color.warn + : theme.color.dim + const queueWindow = 3 + const queueStart = queueEditIdx === null + ? 0 + : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queuedDisplay.length - queueWindow))) + const queueEnd = Math.min(queuedDisplay.length, queueStart + queueWindow) + const queueShown = queuedDisplay.slice(queueStart, queueEnd) + + return ( + + + + {empty ? ( + <> + + {info && } + + {!sid + ? ⚕ {status} + : + type / for commands + {' · '}! for shell + {' · '}Ctrl+C to interrupt + } + + ) : ( + + {theme.brand.icon} + {theme.brand.name} + + + {info?.model ? ` · ${info.model.split('/').pop()}` : ''} + {' · '}{status} + {busy && ' · Ctrl+C to stop'} + + + {usage.total > 0 && ( + + {' · '}{fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) + + )} + + )} + + + {visibleSlice.above > 0 && ( + ↑ {visibleSlice.above} above · PgUp/PgDn to scroll + )} + {messages.slice(visibleSlice.start, visibleSlice.end).map((m, i) => { + const ri = visibleSlice.start + i + return ( + 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0}> + + + ) + })} + {scrollOffset > 0 && ( + ↓ {scrollOffset} below · PgDn or Enter to return + )} + {thinking && } + + + {clarify && ( + { + gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {}) + setMessages(p => [...p, { role: 'user', text: answer }]) + setClarify(null); setStatus('thinking…') + }} /> + )} + + {approval && ( + { + gw.request('approval.respond', { session_id: sid, choice }).catch(() => {}) + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }} /> + )} + + {!blocked && input.startsWith('/') && } + + {queuedDisplay.length > 0 && ( + + + queued ({queuedDisplay.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + + {queueStart > 0 && ( + + )} + {queueShown.map((q, i) => { + const idx = queueStart + i + const active = queueEditIdx === idx + return ( + + {active ? '▸' : ' '} {idx + 1}. {compactPreview(q, Math.max(16, cols - 10))} + + ) + })} + {queueEnd < queuedDisplay.length && ( + + {' '}…and {queuedDisplay.length - queueEnd} more + + )} + + )} + + {'─'.repeat(cols - 2)} + + {!blocked && ( + + + + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + + + + + )} + + + + ) +} + + +function upsert(prev: Msg[], role: Role, text: string): Msg[] { + return prev.at(-1)?.role === role + ? [...prev.slice(0, -1), { role, text }] + : [...prev, { role, text }] +} + + +if (!process.stdin.isTTY) { + console.log('hermes-tui: no TTY (run in a real terminal)') + process.exit(0) +} + +const gw = new GatewayClient() +gw.start() +render(, { exitOnCtrlC: false }) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts new file mode 100644 index 000000000..0a212d4df --- /dev/null +++ b/ui-tui/src/theme.ts @@ -0,0 +1,105 @@ +export interface ThemeColors { + gold: string + amber: string + bronze: string + cornsilk: string + dim: string + + label: string + ok: string + error: string + warn: string + + statusBg: string + statusFg: string + statusGood: string + statusWarn: string + statusBad: string + statusCritical: string +} + +export interface ThemeBrand { + name: string + icon: string + prompt: string + welcome: string + goodbye: string + tool: string +} + +export interface Theme { + color: ThemeColors + brand: ThemeBrand +} + + +export const DEFAULT_THEME: Theme = { + color: { + gold: '#FFD700', + amber: '#FFBF00', + bronze: '#CD7F32', + cornsilk: '#FFF8DC', + dim: '#B8860B', + + label: '#4dd0e1', + ok: '#4caf50', + error: '#ef5350', + warn: '#ffa726', + + statusBg: '#1a1a2e', + statusFg: '#C0C0C0', + statusGood: '#8FBC8F', + statusWarn: '#FFD700', + statusBad: '#FF8C00', + statusCritical: '#FF6B6B', + }, + + brand: { + name: 'Hermes Agent', + icon: '⚕', + prompt: '❯', + welcome: 'Type your message or /help for commands.', + goodbye: 'Goodbye! ⚕', + tool: '┊', + }, +} + + +export function fromSkin( + colors: Record, + branding: Record, +): Theme { + const d = DEFAULT_THEME + const c = (k: string) => colors[k] + + return { + color: { + gold: c('banner_title') ?? d.color.gold, + amber: c('banner_accent') ?? d.color.amber, + bronze: c('banner_border') ?? d.color.bronze, + cornsilk: c('banner_text') ?? d.color.cornsilk, + dim: c('banner_dim') ?? d.color.dim, + + label: c('ui_label') ?? d.color.label, + ok: c('ui_ok') ?? d.color.ok, + error: c('ui_error') ?? d.color.error, + warn: c('ui_warn') ?? d.color.warn, + + statusBg: d.color.statusBg, + statusFg: d.color.statusFg, + statusGood: c('ui_ok') ?? d.color.statusGood, + statusWarn: c('ui_warn') ?? d.color.statusWarn, + statusBad: d.color.statusBad, + statusCritical: d.color.statusCritical, + }, + + brand: { + name: branding.agent_name ?? d.brand.name, + icon: d.brand.icon, + prompt: branding.prompt_symbol ?? d.brand.prompt, + welcome: branding.welcome ?? d.brand.welcome, + goodbye: branding.goodbye ?? d.brand.goodbye, + tool: d.brand.tool, + }, + } +} diff --git a/ui-tui/tsconfig.json b/ui-tui/tsconfig.json new file mode 100644 index 000000000..fe135bfec --- /dev/null +++ b/ui-tui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"] +} From 2818dd8611f5d2ae4722f02c9b0c0af3f8f9793e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 19:34:30 -0500 Subject: [PATCH 002/849] 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 5a1c35069..616d63ef9 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 000000000..12ec3ed7d --- /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 000000000..6cb1c419a --- /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 a5b78523c..e378fa2c6 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 52c400d42..dd404863f 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 f6c74888b..34f88a6a9 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 3d52c9137..afc8a94dd 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 95ae06b1c..b104358bf 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 892853bc9..000000000 --- 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 fa9d1bee4..c035b72ad 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 0a212d4df..caa194f38 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/849] 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 000000000..1eae0cf67 --- /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 dd404863f..2ea39f685 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 000000000..9623d10ad --- /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 000000000..ba46d0147 --- /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 000000000..be7f755d0 --- /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 000000000..c0d71403b --- /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 000000000..36d86acc7 --- /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 000000000..cc9f74388 --- /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 000000000..07119fac3 --- /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 000000000..f5d5cd3da --- /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 000000000..a8d2a99c1 --- /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 000000000..ecb9e4a82 --- /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 000000000..fc265abd4 --- /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 000000000..68aa468c1 --- /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 c035b72ad..ecb9e4a82 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 000000000..4b4084eb4 --- /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/849] chore: uptick --- ui-tui/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore index 1eae0cf67..fc8abe696 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/849] 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 370b69ab0..0ecb677fc 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 616d63ef9..c8262e639 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 9623d10ad..b6ecf42e7 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 c0d71403b..99f24a59b 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 000000000..96fe21e1c --- /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 000000000..baa8ad25b --- /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 f5d5cd3da..f5e7351fa 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 a8d2a99c1..5b921807b 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 000000000..b77ba4443 --- /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 4b4084eb4..253c46069 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/849] 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 72a4537f4..697b75b59 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 ecb9e4a82..cc0a15b4c 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 ecb9e4a82..cc0a15b4c 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/849] 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 b6ecf42e7..6cd3775d3 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 99f24a59b..adee83ad2 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 000000000..01688aca6 --- /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/849] 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 c8262e639..c0e9849ae 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 6cb1c419a..905e734b8 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 6cd3775d3..774e47948 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 be7f755d0..810dc0100 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 adee83ad2..7b1d15775 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 96fe21e1c..f2e8d95ce 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 baa8ad25b..9b5750b09 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 f5e7351fa..8d1fbde21 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 5b921807b..f638b3f43 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 cc0a15b4c..b8c247d97 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 b77ba4443..6300cef3a 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 000000000..07b106c7d --- /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 cc0a15b4c..b8c247d97 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 253c46069..4e3bfce2d 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/849] 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 c0e9849ae..84c86a054 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 774e47948..dacfc9518 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 f638b3f43..87c1fdac2 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/849] 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 000000000..c15ddef7c --- /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 84c86a054..d788b12a3 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 dacfc9518..f170ea8cf 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 36d86acc7..b2e8c914e 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 68aa468c1..a441841c2 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/849] 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 fb0cf0a85..0e1a46df4 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/849] 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 5a9c8d7e9..c08f3f646 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 1e54db9aa..476e6938c 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 000000000..b51962113 --- /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 697b75b59..9284ba28e 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 d788b12a3..7a589db2c 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 f170ea8cf..b52a30508 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 7b1d15775..6c8296cc9 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 07119fac3..7bfe7227a 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 8d1fbde21..e789ee743 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 87c1fdac2..2211a483e 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 b104358bf..332610961 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 a441841c2..c24727484 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/849] 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 b52a30508..51369fc73 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 b2e8c914e..5b9f4659a 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 e789ee743..71e3ebf4f 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/849] 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 51369fc73..ab0121cd6 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 6c8296cc9..d7b3df17f 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/849] 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 7a589db2c..a6e3907c3 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 ab0121cd6..ab41da122 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/849] 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 000000000..4b1e109c0 --- /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/849] 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 a6e3907c3..0b4be63de 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 000000000..5d3741864 --- /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 ab41da122..02b3f6921 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 ba46d0147..45214accb 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 810dc0100..2dad8c04d 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 d7b3df17f..26810a8dd 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 5b9f4659a..6e59f1060 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 000000000..63a19750f --- /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 71e3ebf4f..e5f13992d 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 2211a483e..64666014e 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 6300cef3a..9af62a73f 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 4e3bfce2d..8a34f3cb7 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/849] 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 0b4be63de..195192a5c 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 4b1e109c0..68ec5a43c 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 02b3f6921..f2af728bb 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 26810a8dd..65868c89d 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 6e59f1060..e3058eb07 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 e5f13992d..4d24e812a 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 64666014e..d1abdb78f 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/849] 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 c08f3f646..8688b52d1 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 f2af728bb..a950a14ab 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/849] 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 a950a14ab..0109be3f4 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 45214accb..e18b5523b 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 65868c89d..2abb5bf41 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 e3058eb07..1274a51f2 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 63a19750f..2ac64efb3 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 4d24e812a..183401f8f 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 d1abdb78f..83660c4cb 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 9af62a73f..7beb1516a 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/849] 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 34f88a6a9..000000000 --- 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 0109be3f4..dac76ff8e 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 2dad8c04d..000000000 --- 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 2ac64efb3..389fd27c3 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 000000000..a70012711 --- /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 000000000..a7b7d2eca --- /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 000000000..c0df224ff --- /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 7beb1516a..50125d3b5 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 07b106c7d..000000000 --- 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 b8c247d97..000000000 --- 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/849] 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 dac76ff8e..858aadec2 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/849] 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 a7b7d2eca..0793178fd 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/849] 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 195192a5c..67f9f0c47 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 e378fa2c6..85d196129 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 858aadec2..574f69f7b 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 afc8a94dd..6324cafe1 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 e18b5523b..cd8f0b9d5 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 caa194f38..29db1480f 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/849] 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 67f9f0c47..dd375b836 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 5d3741864..631b0c704 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 574f69f7b..5f31d8385 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/849] 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 8688b52d1..12ddd15d6 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 d644c6221..80ed53cce 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 c04dc4a9d..b44f538fa 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/849] 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 5f31d8385..740e0ffcd 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 1274a51f2..5e8b84354 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/849] 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 740e0ffcd..e18adba99 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 2abb5bf41..9a75952f5 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 29db1480f..37f1d5bde 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/849] 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 68ec5a43c..89719a43b 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 000000000..c93554b99 --- /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 2ea39f685..ccf17b20b 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 e18adba99..31be2cb9f 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 5e8b84354..676e10cb4 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 000000000..717a1a798 --- /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 389fd27c3..9e7e5ab10 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 183401f8f..a9f4ceede 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 83660c4cb..f72ce34bf 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 8a34f3cb7..7d287dc38 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/849] 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 85d196129..000000000 --- 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 31be2cb9f..94144b9aa 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 c7febfd4a..527a9641c 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 676e10cb4..cdec6f3e7 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 9e7e5ab10..38d45358c 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 a9f4ceede..a813765bb 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 f72ce34bf..63e5f8da3 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 37f1d5bde..3ae7ada19 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 7d287dc38..5c6f0a76a 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/849] 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 94144b9aa..2fed081e2 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/849] 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 8045c3d21..4a668caac 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 000000000..e69de29bb diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py new file mode 100644 index 000000000..7e9d519ee --- /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 000000000..3054846b8 --- /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 89719a43b..5ff56e617 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 000000000..03c9c3397 --- /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 ccf17b20b..5100edbd2 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 000000000..0a864f160 --- /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 000000000..0ce715055 --- /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 000000000..322d29294 --- /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 000000000..ea0011a51 --- /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/849] 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 0a864f160..c46988307 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 0ce715055..8f6a265f1 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 322d29294..be93126c7 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 ea0011a51..86a9768b0 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 2fed081e2..93fc5159c 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/849] 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 d4793db59..e5ddf497a 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 dd375b836..654c9e9e3 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 5ff56e617..8783b18fb 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 93fc5159c..0ac815611 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 cdec6f3e7..71246e473 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 38d45358c..e7b92dc38 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 a813765bb..b2aff0355 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 63e5f8da3..9734b0c27 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 5c6f0a76a..3254c2674 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/849] 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 e5ddf497a..35dc605f9 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 f57072e9e..fd1337cbb 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 000000000..1d4ff429a --- /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 7e9d519ee..7a000c92b 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 654c9e9e3..9977d40f5 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 be93126c7..e96cbac3d 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 0ac815611..d17d0c0fb 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 71246e473..76d0d1743 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 9734b0c27..36dd999e6 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 c24727484..ac2efb2cb 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/849] 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 e96cbac3d..1d61b71b1 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 d17d0c0fb..5a66dce58 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 ac2efb2cb..264d741fd 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/849] 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 9977d40f5..da603b727 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 5a66dce58..16152bcf9 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 8b4dc5c33..54dcb17eb 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 b8c247d97..29d034957 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/849] 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 622c087f3..3545f4baa 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 82a4aa6fa..d64637ca7 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 da603b727..059bbc394 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 8783b18fb..f9fbcd3f2 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 16152bcf9..eae404371 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 54dcb17eb..6ec5cc5fc 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 36dd999e6..c244e6b58 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/849] 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 eae404371..2efaca53e 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 6ec5cc5fc..9ec083c9d 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/849] 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 5100edbd2..177cdd05a 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 29d034957..68dc9c0b7 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 332610961..5a3eac5e8 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 fe135bfec..b7817e13a 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/849] 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 03c9c3397..18a63d688 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/849] 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 c838639ba..58761dcb0 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 de0e61060..fbe791049 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/849] 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 000000000..0e88ea08c --- /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/849] 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 eb50d4a17..8795e35c8 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/849] 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 7f8b5a1b0..db39c9d95 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/849] 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 6dd5115c9..55068a94f 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/849] direnv: watch lockfiles/nix files; gitignore .nix-stamps --- .envrc | 4 ++++ .gitignore | 1 + 2 files changed, 5 insertions(+) diff --git a/.envrc b/.envrc index 3550a30f2..a98c03b2c 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 baa31a543..b3cc8bfff 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/849] 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 9a75952f5..5882ab8c7 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 264d741fd..44d6a3152 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/849] 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 059bbc394..f0b80ad50 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 2efaca53e..96bbfc720 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 c244e6b58..2d755c342 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/849] 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 fa32ae911..8b5bfea2d 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 bd07459ac..aa40ece6d 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 339954f5b..91e4a7d56 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 70d9cb8aa..917e8b1e0 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 fc0470683..d7234f296 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 000000000..6cd010df3 --- /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 e90dee69c..e36a1473f 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 0dbd5980b..1cd58f3c6 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 81c262a84..3b57bf07a 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 b935f49c3..2adad9e47 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 96bbfc720..726ea9f9c 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/849] 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 726ea9f9c..0a0ec2f03 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 76d0d1743..91d1fe8c3 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 b2aff0355..be30a3dc4 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/849] 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 c46988307..36ca9a0ad 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 1d61b71b1..55b6a272b 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 0a0ec2f03..06399ba7a 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 be30a3dc4..5dbcfdab4 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 2d755c342..59fc63928 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 44d6a3152..ddb6f9fdd 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/849] 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 06399ba7a..88dcf84e6 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 91d1fe8c3..8b8b30894 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 5dbcfdab4..f4f5130ee 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 ddb6f9fdd..7f835c0cd 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 3254c2674..1cfa03540 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/849] 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 f9fbcd3f2..9992cd340 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 18a63d688..81b44fc53 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 9ec083c9d..f5deb9c49 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/849] 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 917e8b1e0..a679817b3 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 d7234f296..499843585 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 f0b80ad50..023da60b1 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 88dcf84e6..255a62d0e 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/849] chore: fix bad merge apparently? --- cli.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cli.py b/cli.py index 6303e54f7..237ed7899 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/849] 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 0ecb677fc..fa4981c1a 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 023da60b1..c204e1904 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 255a62d0e..25c87205c 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 1cfa03540..0c87b2cc7 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/849] 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 68dc9c0b7..3f719f4e1 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/849] 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 a98c03b2c..45c59523c 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 78ceba92d..ad530ea1d 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 919fa434d..fcb5eaa61 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 8795e35c8..924ce2d17 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 0e88ea08c..76bab7f53 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 81b44fc53..f8dd19a16 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/849] 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 76bab7f53..a3a8e1a64 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 f8dd19a16..4d593f4ab 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/849] 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 a3a8e1a64..5b0ea3268 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 4d593f4ab..1a0cb4859 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/849] 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 e7455608c..2ccd1ba9c 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/849] 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 c204e1904..e53694d1d 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 9b5750b09..a793e52a5 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/849] 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 25c87205c..91f45eabf 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/849] 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 a793e52a5..41c033500 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/849] 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 e53694d1d..3c9120113 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/849] 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 3c9120113..5f50ab630 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 91f45eabf..e6eba6209 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 7f835c0cd..e1364d8de 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/849] 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 905e734b8..7013dfdb6 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 1a0cb4859..ec79588fe 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 177cdd05a..2fc6271f8 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 000000000..1c23959a3 --- /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 000000000..be929ce6c --- /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 000000000..6741a24f9 --- /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 000000000..dcbae499f --- /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 000000000..0aa7e1f20 --- /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 000000000..fde397af2 --- /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 000000000..e37eca558 --- /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+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvVGV4dD5cbiAgICApXG4gIH1cbiAgcmV0dXJuIDxUZXh0IHsuLi5yZXN0fT57Y2hpbGRyZW59PC9UZXh0PlxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsT0FBT0MsSUFBSSxNQUFNLHNCQUFzQjtBQUN2QyxPQUFPQyxJQUFJLE1BQU0sc0JBQXNCO0FBQ3ZDLGNBQWNDLEtBQUssUUFBUSxhQUFhO0FBQ3hDLFNBQ0UsS0FBS0MsVUFBVSxFQUNmQyxNQUFNLEVBQ04sS0FBS0YsS0FBSyxJQUFJRyxXQUFXLEVBQ3pCLEtBQUtDLFNBQVMsUUFDVCxhQUFhO0FBRXBC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 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 000000000..28edace8a --- /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 000000000..4ccaeeace --- /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 000000000..ebc3159b7 --- /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 000000000..757f7789b --- /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 000000000..d288d28ba --- /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/IDIgOiAzXG4gICAgICBhcHAucHJvcHMub25NdWx0aUNsaWNrKGNvbCwgcm93LCBjb3VudClcbiAgICAgIHJldHVyblxuICAgIH1cbiAgICBzdGFydFNlbGVjdGlvbihzZWwsIGNvbCwgcm93KVxuICAgIC8vIFNHUiBiaXQgMHgwOCA9IGFsdCAoeHRlcm0uanMgd2lyZXMgYWx0S2V5IGhlcmUsIG5vdCBtZXRhS2V5IOKAlCBzZWVcbiAgICAvLyBjb21tZW50IGF0IHRoZSBoeXBlcmxpbmstb3BlbiBndWFyZCBiZWxvdykuIE9uIG1hY09TIHh0ZXJtLmpzLFxuICAgIC8vIHJlY2VpdmluZyBhbHQgbWVhbnMgbWFjT3B0aW9uQ2xpY2tGb3JjZXNTZWxlY3Rpb24gaXMgT0ZGIChvdGhlcndpc2VcbiAgICAvLyB4dGVybS5qcyB3b3VsZCBoYXZlIGNvbnN1bWVkIHRoZSBldmVudCBmb3IgbmF0aXZlIHNlbGVjdGlvbikuXG4gICAgc2VsLmxhc3RQcmVzc0hhZEFsdCA9IChtLmJ1dHRvbiAmIDB4MDgpICE9PSAwXG4gICAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbiAgICByZXR1cm5cbiAgfVxuXG4gIC8vIFJlbGVhc2U6IGVuZCB0aGUgZHJhZyBldmVuIGZvciBub24temVybyBidXR0b24gY29kZXMuIFNvbWUgdGVybWluYWxzXG4gIC8vIGVuY29kZSByZWxlYXNlIHdpdGggdGhlIG1vdGlvbiBiaXQgb3IgYnV0dG9uPTMgXCJubyBidXR0b25cIiAoY2FycmllZFxuICAvLyBvdmVyIGZyb20gcHJlLVNHUiBYMTAgZW5jb2RpbmcpIOKAlCBmaWx0ZXJpbmcgdGhvc2Ugd291bGQgb3JwaGFuXG4gIC8vIGlzRHJhZ2dpbmc9dHJ1ZSBhbmQgbGVhdmUgZHJhZy10by1zY3JvbGwncyB0aW1lciBydW5uaW5nIHVudGlsIHRoZVxuICAvLyBzY3JvbGwgYm91bmRhcnkuIE9ubHkgYWN0IG9uIG5vbi1sZWZ0IHJlbGVhc2VzIHdoZW4gd2UgQVJFIGRyYWdnaW5nXG4gIC8vIChzbyBhbiB1bnJlbGF0ZWQgbWlkZGxlL3JpZ2h0IGNsaWNrLXJlbGVhc2UgZG9lc24ndCB0b3VjaCBzZWxlY3Rpb24pLlxuICBpZiAoYmFzZUJ1dHRvbiAhPT0gMCkge1xuICAgIGlmICghc2VsLmlzRHJhZ2dpbmcpIHJldHVyblxuICAgIGZpbmlzaFNlbGVjdGlvbihzZWwpXG4gICAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbiAgICByZXR1cm5cbiAgfVxuICBmaW5pc2hTZWxlY3Rpb24oc2VsKVxuICAvLyBOT1RFOiB1bmxpa2UgdGhlIG9sZCByZWxlYXNlLWJhc2VkIGRldGVjdGlvbiB3ZSBkbyBOT1QgcmVzZXQgY2xpY2tDb3VudFxuICAvLyBvbiByZWxlYXNlLWFmdGVyLWRyYWcuIFRoaXMgYWxpZ25zIHdpdGggTlNFdmVudC5jbGlja0NvdW50IHNlbWFudGljczpcbiAgLy8gYW4gaW50ZXJ2ZW5pbmcgZHJhZyBkb2Vzbid0IGJyZWFrIHRoZSBjbGljayBjaGFpbi4gUHJhY3RpY2FsIHVwc2lkZTpcbiAgLy8gdHJhY2twYWQgaml0dGVyIGR1cmluZyBhbiBpbnRlbmRlZCBkb3VibGUtY2xpY2sgKHByZXNz4oaSd29iYmxl4oaScmVsZWFzZVxuICAvLyDihpJwcmVzcykgbm93IGNvcnJlY3RseSByZXNvbHZlcyB0byB3b3JkLXNlbGVjdCBpbnN0ZWFkIG9mIGJyZWFraW5nIHRvIGFcbiAgLy8gZnJlc2ggc2luZ2xlIGNsaWNrLiBUaGUgbmVhckxhc3Qgd2luZG93ICg1MDBtcywgMSBjZWxsKSBib3VuZHMgdGhlXG4gIC8vIGVmZmVjdCDigJQgYSBkZWxpYmVyYXRlIGRyYWcgcGFzdCB0aGF0IGp1c3Qgc3RhcnRzIGEgZnJlc2ggY2hhaW4uXG4gIC8vIEEgcHJlc3MrcmVsZWFzZSB3aXRoIG5vIGRyYWcgaW4gY2hhciBtb2RlIGlzIGEgY2xpY2s6IGFuY2hvciBzZXQsXG4gIC8vIGZvY3VzIG51bGwg4oaSIGhhc1NlbGVjdGlvbiBmYWxzZS4gSW4gd29yZC9saW5lIG1vZGUgdGhlIHByZXNzIGFscmVhZHlcbiAgLy8gc2V0IGFuY2hvcitmb2N1cyAoaGFzU2VsZWN0aW9uIHRydWUpLCBzbyByZWxlYXNlIGp1c3Qga2VlcHMgdGhlXG4gIC8vIGhpZ2hsaWdodC4gVGhlIGFuY2hvciBjaGVjayBndWFyZHMgYWdhaW5zdCBhbiBvcnBoYW5lZCByZWxlYXNlIChub1xuICAvLyBwcmlvciBwcmVzcyDigJQgZS5nLiBidXR0b24gd2FzIGhlbGQgd2hlbiBtb3VzZSB0cmFja2luZyB3YXMgZW5hYmxlZCkuXG4gIGlmICghaGFzU2VsZWN0aW9uKHNlbCkgJiYgc2VsLmFuY2hvcikge1xuICAgIC8vIFNpbmdsZSBjbGljazogZGlzcGF0Y2ggRE9NIGNsaWNrIGltbWVkaWF0ZWx5IChjdXJzb3IgcmVwb3NpdGlvbmluZ1xuICAgIC8vIGV0Yy4gYXJlIGxhdGVuY3ktc2Vuc2l0aXZlKS4gSWYgbm8gRE9NIGhhbmRsZXIgY29uc3VtZWQgaXQsIGRlZmVyXG4gICAgLy8gdGhlIGh5cGVybGluayBjaGVjayBzbyBhIHNlY29uZCBjbGljayBjYW4gY2FuY2VsIGl0LlxuICAgIGlmICghYXBwLnByb3BzLm9uQ2xpY2tBdChjb2wsIHJvdykpIHtcbiAgICAgIC8vIFJlc29sdmUgdGhlIGh5cGVybGluayBVUkwgc3luY2hyb25vdXNseSB3aGlsZSB0aGUgc2NyZWVuIGJ1ZmZlclxuICAgICAgLy8gc3RpbGwgcmVmbGVjdHMgd2hhdCB0aGUgdXNlciBjbGlja2VkIOKAlCBkZWZlcnJpbmcgb25seSB0aGVcbiAgICAgIC8vIGJyb3dzZXItb3BlbiBzbyBkb3VibGUtY2xpY2sgY2FuIGNhbmNlbCBpdC5cbiAgICAgIGNvbnN0IHVybCA9IGFwcC5wcm9wcy5nZXRIeXBlcmxpbmtBdChjb2wsIHJvdylcbiAgICAgIC8vIHh0ZXJtLmpzIChWUyBDb2RlLCBDdXJzb3IsIFdpbmRzdXJmLCBldGMuKSBoYXMgaXRzIG93biBPU0MgOCBsaW5rXG4gICAgICAvLyBoYW5kbGVyIHRoYXQgZmlyZXMgb24gQ21kK2NsaWNrICp3aXRob3V0IGNvbnN1bWluZyB0aGUgbW91c2UgZXZlbnQqXG4gICAgICAvLyAoTGlua2lmaWVyLl9oYW5kbGVNb3VzZVVwIGNhbGxzIGxpbmsuYWN0aXZhdGUoKSBidXQgbmV2ZXJcbiAgICAgIC8vIHByZXZlbnREZWZhdWx0L3N0b3BQcm9wYWdhdGlvbikuIFRoZSBjbGljayBpcyBhbHNvIGZvcndhcmRlZCB0byB0aGVcbiAgICAgIC8vIHB0eSBhcyBTR1IsIHNvIGJvdGggVlMgQ29kZSdzIHRlcm1pbmFsTGlua01hbmFnZXIgQU5EIG91ciBoYW5kbGVyXG4gICAgICAvLyBoZXJlIHdvdWxkIG9wZW4gdGhlIFVSTCDigJQgdHdpY2UuIFdlIGNhbid0IGZpbHRlciBvbiBDbWQ6IHh0ZXJtLmpzXG4gICAgICAvLyBkcm9wcyBtZXRhS2V5IGJlZm9yZSBTR1IgZW5jb2RpbmcgKElDb3JlTW91c2VFdmVudCBoYXMgbm8gbWV0YVxuICAgICAgLy8gZmllbGQ7IHRoZSBTR1IgYml0IHdlIGNhbGwgJ21ldGEnIGlzIHdpcmVkIHRvIGFsdCkuIExldCB4dGVybS5qc1xuICAgICAgLy8gb3duIGxpbmstb3BlbmluZzsgQ21kK2NsaWNrIGlzIHRoZSBuYXRpdmUgVVggdGhlcmUgYW55d2F5LlxuICAgICAgLy8gVEVSTV9QUk9HUkFNIGlzIHRoZSBzeW5jIGZhc3QtcGF0aDsgaXNYdGVybUpzKCkgaXMgdGhlIFhUVkVSU0lPTlxuICAgICAgLy8gcHJvYmUgcmVzdWx0IChjYXRjaGVzIFNTSCArIG5vbi1WUyBDb2RlIGVtYmVkZGVycyBsaWtlIEh5cGVyKS5cbiAgICAgIGlmICh1cmwgJiYgcHJvY2Vzcy5lbnYuVEVSTV9QUk9HUkFNICE9PSAndnNjb2RlJyAmJiAhaXNYdGVybUpzKCkpIHtcbiAgICAgICAgLy8gQ2xlYXIgYW55IHByaW9yIHBlbmRpbmcgdGltZXIg4oCUIGNsaWNraW5nIGEgc2Vjb25kIGxpbmtcbiAgICAgICAgLy8gc3VwZXJzZWRlcyB0aGUgZmlyc3QgKG9ubHkgdGhlIGxhdGVzdCBjbGljayBvcGVucykuXG4gICAgICAgIGlmIChhcHAucGVuZGluZ0h5cGVybGlua1RpbWVyKSB7XG4gICAgICAgICAgY2xlYXJUaW1lb3V0KGFwcC5wZW5kaW5nSHlwZXJsaW5rVGltZXIpXG4gICAgICAgIH1cbiAgICAgICAgYXBwLnBlbmRpbmdIeXBlcmxpbmtUaW1lciA9IHNldFRpbWVvdXQoXG4gICAgICAgICAgKGFwcCwgdXJsKSA9PiB7XG4gICAgICAgICAgICBhcHAucGVuZGluZ0h5cGVybGlua1RpbWVyID0gbnVsbFxuICAgICAgICAgICAgYXBwLnByb3BzLm9uT3Blbkh5cGVybGluayh1cmwpXG4gICAgICAgICAgfSxcbiAgICAgICAgICBNVUxUSV9DTElDS19USU1FT1VUX01TLFxuICAgICAgICAgIGFwcCxcbiAgICAgICAgICB1cmwsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9XG4gIH1cbiAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsS0FBSyxJQUFJQyxhQUFhLEVBQUUsS0FBS0MsU0FBUyxRQUFRLE9BQU87QUFDNUQsU0FBU0MseUJBQXlCLFFBQVEsMEJBQTBCO0FBQ3BFLFNBQVNDLGVBQWUsUUFBUSxzQkFBc0I7QUFDdEQsU0FBU0MsdUJBQXVCLFFBQVEsMkJBQTJCO0FBQ25FLFNBQVNDLFdBQVcsUUFBUSx5QkFBeUI7QUFDckQsU0FBU0MscUJBQXFCLFFBQVEsMkJBQTJCO0FBQ2pFLFNBQVNDLFFBQVEsUUFBUSxvQkFBb0I7QUFDN0MsU0FBU0MsWUFBWSxRQUFRLHNCQUFzQjtBQUNuRCxTQUFTQyxVQUFVLFFBQVEsMEJBQTBCO0FBQ3JELFNBQVNDLGtCQUFrQixRQUFRLG1DQUFtQztBQUN0RSxTQUNFQyxhQUFhLEVBQ2IsS0FBS0MsV0FBVyxFQUNoQixLQUFLQyxTQUFTLEVBQ2QsS0FBS0MsV0FBVyxFQUNoQkMsdUJBQXVCLFFBQ2xCLHNCQUFzQjtBQUM3QixPQUFPQyxVQUFVLE1BQU0sa0JBQWtCO0FBQ3pDLFNBQ0VDLGVBQWUsRUFDZkMsWUFBWSxF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 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 000000000..3d13e779c --- /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 000000000..13ec46995 --- /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+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQm94XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLEVBQUUsS0FBS0MsR0FBRyxRQUFRLE9BQU87QUFDL0QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBTyxLQUFLQyxJQUFJLE1BQU0sWUFBWTtBQUVsQyxPQUFPLEtBQUtDLEtBQUssR0FBR1AsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NHLEdBQUcsQ0FBQyxFQUFFVCxHQUFHLENBQUNFLFVBQVUsQ0FBQztFQUNyQjtBQUNGO0FBQ0E7QUFDQTtFQUNFUSxRQUFRLENBQUMsRUFBRSxNQUFNO0VBQ2pCO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsU0FBUyxDQUFDLEVBQUUsT0FBTztFQUNu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 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 000000000..e99034c6d --- /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+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQnV0dG9uXG5leHBvcnQgdHlwZSB7IEJ1dHRvblN0YXRlIH1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU9BLEtBQUssSUFDVixLQUFLQyxHQUFHLEVBQ1JDLFdBQVcsRUFDWEMsU0FBUyxFQUNUQyxNQUFNLEVBQ05DLFFBQVEsUUFDSCxPQUFPO0FBQ2QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBT0MsR0FBRyxNQUFNLFVBQVU7QUFFMUIsS0FBS0MsV0FBVyxHQUFHO0VBQ2pCQyxPQUFPLEVBQUUsT0FBTztFQUNoQkMsT0FBTyxFQUFFLE9BQU87RUFDaEJDLE1BQU0sRUFBRSxPQUFPO0FBQ2pCLENBQUM7QUFFRCxPQUFPLEtBQUtDLEtBQUssR0FBR1gsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NPLEdBQUcsQ0FBQyxFQUFFakIsR0FBRyxDQUFDTSxVQUFVLENBQUM7RUFDckI7QUFDRjtBQUNBO0VBQ0VZLFFBQVEsRUFBRSxHQUFHLEdBQUcsSUFBSTtFQUNwQjtB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 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 000000000..521cd5751 --- /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 000000000..37356afa1 --- /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 000000000..9e87788e6 --- /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+XG4gIClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsV0FBVyxJQUFJLEtBQUtDLFdBQVcsUUFBUSxjQUFjO0FBQzVELFNBQVNDLFlBQVksUUFBUSxJQUFJO0FBQ2pDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLE9BQU9DLFVBQVUsTUFBTSxhQUFhO0FBQ3BDLE9BQU9DLEdBQUcsTUFBTSxVQUFVO0FBQzFCLE9BQU9DLElBQUksTUFBTSxXQUFXOztBQUU1Qjs7QUFFQTtBQUNBO0FBQ0EsTUFBTUMsV0FBVyxHQUFHQSxDQUFDQyxJQUFJLEVBQUUsTUFBTSxHQUFHLFNBQVMsQ0FBQyxFQUFFLE1BQU0sR0FBRyxTQUFTLElBQUk7RUFDcEUsT0FBT0EsSUFBSSxFQUFFQyxPQUFPLENBQUMsVUFBVUMsT0FBTyxDQUFDQyxHQUFHLENBQUMsQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDO0FBQ3RELENBQUM7QUFFRCxJQUFJQyxVQUFVLEVBQUVSLFVBQVUsR0FBRyxTQUFTO0FBQ3RDLFNBQVNTLGFBQWFBLENBQUEsQ0FBRSxFQUFFVCxVQUFVLENBQUM7RUFDbkMsT0FBUVEsVUFBVSxLQUFLLElBQUlSLFVBQVUsQ0FBQztJQUNwQ08sR0FBRyxFQUFFRCxPQUFPLENBQUNDLEdBQUcsQ0FBQyxDQUFDO0lBQ2xCRyxTQUFTLEVBQUVWLFVBQVUsQ0FBQ1csYUFBYSxDQUFDO0VBQ3RDLENBQUMsQ0FBQztBQUNKOztBQUVBOztBQUVBLEtBQUtDLEtBQUssR0FBRztFQUNYLFNBQVNDLEtBQUssRUFBRUMsS0FBSztBQUN2QixDQUFDO0FBRUQsZUFBZSxTQUFTQyxhQUFhQSxDQUFD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 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 000000000..72c94fa11 --- /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 000000000..54dfa50fa --- /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 000000000..e3da69852 --- /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 000000000..2c0b2f0fe --- /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 000000000..e7b55e71d --- /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+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvQm94PlxuICAgIDwvaW5rLWJveD5cbiAgKVxufVxuXG5leHBvcnQgZGVmYXVsdCBTY3JvbGxCb3hcbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsS0FBSyxJQUNWLEtBQUtDLGlCQUFpQixFQUN0QixLQUFLQyxHQUFHLEVBQ1JDLG1CQUFtQixFQUNuQkMsTUFBTSxFQUNOQyxRQUFRLFFBQ0gsT0FBTztBQUNkLGNBQWNDLE1BQU0sUUFBUSxXQUFXO0FBQ3ZDLFNBQVNDLGtCQUFrQixRQUFRLDBCQUEwQjtBQUM3RCxjQUFjQyxVQUFVLFFBQVEsV0FBVztBQUMzQyxTQUFTQyxTQUFTLEVBQUVDLGtCQUFrQixRQUFRLFdBQVc7QUFDekQsU0FBU0MsZUFBZSxRQUFRLGtCQUFrQjtBQUNsRCxjQUFjQyxNQUFNLFFBQVEsY0FBYztBQUMxQyxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQyxHQUFHLE1BQU0sVUFBVTtBQUUxQixPQUFPLEtBQUtDLGVBQWUsR0FBRztFQUM1QkMsUUFBUSxFQUFFLENBQUNDLENBQUMsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzdCQyxRQUFRLEVBQUUsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7RUFDOUI7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsZUFBZSxFQUFFLENBQUNDLEVBQUUsRUFBRVosVUFBVSxFQUFFYSxNQUFlLENBQVIsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzFEQyxjQUFjLEVBQUUsR0FBRyxHQUFHLElBQUk7RUFDMUJDLFlBQVksRUFBRSxHQUFHLEdBQUcsTUFBTTtFQUMxQkMsZUFBZSxFQUFFLEdB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 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 000000000..3ed7609b8 --- /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 000000000..c6e9334df --- /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 000000000..02860485a --- /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 000000000..ec743b3a0 --- /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 000000000..f69d338c1 --- /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/OiBuZXZlcjsgZGltPzogbmV2ZXIgfVxuICB8IHsgYm9sZDogYm9vbGVhbjsgZGltPzogbmV2ZXIgfVxuICB8IHsgZGltOiBib29sZWFuOyBib2xkPzogbmV2ZXIgfVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEJhc2VQcm9wcyAmIFdlaWdodFByb3BzXG5cbmNvbnN0IG1lbW9pemVkU3R5bGVzRm9yV3JhcDogUmVjb3JkPE5vbk51bGxhYmxlPFN0eWxlc1sndGV4dFdyYXAnXT4sIFN0eWxlcz4gPSB7XG4gIHdyYXA6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnd3JhcCcsXG4gIH0sXG4gICd3cmFwLXRyaW0nOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3dyYXAtdHJpbScsXG4gIH0sXG4gIGVuZDoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICdlbmQnLFxuICB9LFxuICBtaWRkbGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnbWlkZGxlJyxcbiAgfSxcbiAgJ3RydW5jYXRlLWVuZCc6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUtZW5kJyxcbiAgfSxcbiAgdHJ1bmNhdGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUnLFxuICB9LFxuICAndHJ1bmNhdGUtbWlkZGxlJzoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICd0cnVuY2F0ZS1taWRkbGUnLFxuICB9LFxuICAndHJ1bmNhdGUtc3RhcnQnOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3RydW5jYXRlLXN0YXJ0JyxcbiAgfSxcbn0gYXMgY29uc3RcblxuLyoqXG4gKiBUaGlzIGNvbXBvbmVudCBjYW4gZGlzcGxheSB0ZXh0LCBhbmQgY2hhbmdlIGl0cyBzdHlsZSB0byBtYWtlIGl0IGNvbG9yZnVsLCBib2xkLCB1bmRlcmxpbmUsIGl0YWxpYyBvciBzdHJpa2V0aHJvdWdoLlxuICovXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBUZXh0KHtcbiAgY29sb3IsXG4gIGJhY2tncm91bmRDb2xvcixcbiAgYm9sZCxcbiAgZGltLFxuICBpdGFsaWMgPSBmYWxzZSxcbiAgdW5kZXJsaW5lID0gZmFsc2UsXG4gIHN0cmlrZXRocm91Z2ggPSBmYWxzZSxcbiAgaW52ZXJzZSA9IGZhbHNlLFxuICB3cmFwID0gJ3dyYXAnLFxuICBjaGlsZHJlbixcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgaWYgKGNoaWxkcmVuID09PSB1bmRlZmluZWQgfHwgY2hpbGRyZW4gPT09IG51bGwpIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgLy8gQnVpbGQgdGV4dFN0eWxlcyBvYmplY3Qgd2l0aCBvbmx5IHRoZSBwcm9wZXJ0aWVzIHRoYXQgYXJlIHNldFxuICBjb25zdCB0ZXh0U3R5bGVzOiBUZXh0U3R5bGVzID0ge1xuICAgIC4uLihjb2xvciAmJiB7IGNvbG9yIH0pLFxuICAgIC4uLihiYWNrZ3JvdW5kQ29sb3IgJiYgeyBiYWNrZ3JvdW5kQ29sb3IgfSksXG4gICAgLi4uKGRpbSAmJiB7IGRpbSB9KSxcbiAgICAuLi4oYm9sZCAmJiB7IGJvbGQgfSksXG4gICAgLi4uKGl0YWxpYyAmJiB7IGl0YWxpYyB9KSxcbiAgICAuLi4odW5kZXJsaW5lICYmIHsgdW5kZXJsaW5lIH0pLFxuICAgIC4uLihzdHJpa2V0aHJvdWdoICYmIHsgc3RyaWtldGhyb3VnaCB9KSxcbiAgICAuLi4oaW52ZXJzZSAmJiB7IGludmVyc2UgfSksXG4gIH1cblxuICByZXR1cm4gKFxuICAgIDxpbmstdGV4dCBzdHlsZT17bWVtb2l6ZWRTdHlsZXNGb3JXcmFwW3dyYXBdfSB0ZXh0U3R5bGVzPXt0ZXh0U3R5bGVzfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L2luay10ZXh0PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxjQUFjQSxTQUFTLFFBQVEsT0FBTztBQUN0QyxPQUFPQyxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxLQUFLLEVBQUVDLE1BQU0sRUFBRUMsVUFBVSxRQUFRLGNBQWM7QUFFN0QsS0FBS0MsU0FBUyxHQUFHO0VBQ2Y7QUFDRjtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUVKLEtBQUs7O0VBRXRCO0FBQ0Y7QUFDQTtFQUNFLFNBQVNLLGVBQWUsQ0FBQyxFQUFFTCxLQUFLOztFQUVoQztBQUNGO0FBQ0E7RUFDRSxTQUFTTSxNQUFNLENBQUMsRUFBRSxPQUFPOztFQUV6QjtBQUNGO0FBQ0E7RUFDRSxTQUFTQyxTQUFTLENBQUMsRUFBRSxPQUFPOztFQUU1QjtBQUNGO0FBQ0E7RUFDRSxTQUFTQyxhQUFhLENBQUMsRUFBRSxPQUFPOztFQUVoQztBQUNGO0FBQ0E7RUFDRSxTQUFTQyxPQUFPLENBQUMsRUFBRSxPQUFPOztFQUUxQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsSUFBSSxDQUFDLEVBQUVULE1BQU0sQ0FBQyxVQUFVLENBQUM7RUFFbEMsU0FBU1UsUUFBUSxDQUFDLEVBQUViLFNBQVM7QUFDL0IsQ0FBQzs7QUFFRDtBQUNBO0FBQ0E7QUFDQTtBQUNBLEtBQUtjLFdBQVcsR0FDWjtF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 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 000000000..1846997c0 --- /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 000000000..fd3781671 --- /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 000000000..121cd8b9b --- /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 000000000..1f58659a8 --- /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 000000000..1357da1dd --- /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 000000000..d00c4d9e3 --- /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 000000000..42d59d035 --- /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 000000000..61874002e --- /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 000000000..527fd26d2 --- /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 000000000..293ecdbee --- /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 000000000..6d441dadb --- /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 000000000..9a86bf8b2 --- /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 000000000..6d0303fdb --- /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 000000000..0317ed9d7 --- /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 000000000..869afa5f9 --- /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 000000000..e07946374 --- /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 000000000..336ce12bb --- /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 000000000..f0d9a3179 --- /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 000000000..0eef9e1ab --- /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 000000000..9c0603244 --- /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 000000000..288a92eda --- /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 000000000..edda48a4a --- /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 000000000..af568457b --- /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 000000000..f43379a5e --- /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 000000000..58761fe24 --- /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 000000000..58cf746f5 --- /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 000000000..a3cdf17bc --- /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 000000000..230d87a39 --- /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 000000000..6b5b28f5c --- /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 000000000..ada3059d9 --- /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 000000000..5b15167d5 --- /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+IHZvaWRcbiAgfVxuICBjb25zdCB3YXNSYXcgPSB0dHkuaXNSYXcgPT09IHRydWVcbiAgLy8gRHJhaW4gdGhlIGtlcm5lbCBUVFkgYnVmZmVyIHZpYSBhIGZyZXNoIE9fTk9OQkxPQ0sgZmQuIEJvdW5kZWQgYXQgNjRcbiAgLy8gcmVhZHMgKDY0S0IpIOKAlCBhIHJlYWwgbW91c2UgYnVyc3QgaXMgYSBmZXcgaHVuZHJlZCBieXRlczsgdGhlIGNhcFxuICAvLyBndWFyZHMgYWdhaW5zdCBhIHRlcm1pbmFsIHRoYXQgaWdub3JlcyBPX05PTkJMT0NLLlxuICBsZXQgZmQgPSAtMVxuICB0cnkge1xuICAgIC8vIHNldFJhd01vZGUgaW5zaWRlIHRyeTogb24gcmV2b2tlZCBUVFkgKFNJR0hVUC9TU0ggZGlzY29ubmVjdCkgdGhlXG4gICAgLy8gaW9jdGwgdGhyb3dzIEVCQURGIOKAlCBzYW1lIHJlY292ZXJ5IHBhdGggYXMgb3BlblN5bmMvcmVhZFN5bmMgYmVsb3cuXG4gICAgaWYgKCF3YXNSYXcpIHR0eS5zZXRSYXdNb2RlPy4odHJ1ZSlcbiAgICBmZCA9IG9wZW5TeW5jKCcvZGV2L3R0eScsIGZzQ29uc3RhbnRzLk9fUkRPTkxZIHwgZnNDb25zdGFudHMuT19OT05CTE9DSylcbiAgICBjb25zdCBidWYgPSBCdWZmZXIuYWxsb2MoMTAyNClcbiAgICBmb3IgKGxldCBpID0gMDsgaSA8IDY0OyBpKyspIHtcbiAgICAgIGlmIChyZWFkU3luYyhmZCwgYnVmLCAwLCBidWYubGVuZ3RoLCBudWxsKSA8PSAwKSBicmVha1xuICAgIH1cbiAgfSBjYXRjaCB7XG4gICAgLy8gRUFHQUlOIChidWZmZXIgZW1wdHkg4oCUIGV4cGVjdGVkKSwgRU5YSU8vRU5PRU5UIChubyBjb250cm9sbGluZyB0dHkpLFxuICAgIC8vIEVCQURGL0VJTyAoVFRZIHJldm9rZWQg4oCUIFNJR0hVUCwgU1NIIGRpc2Nvbm5lY3QpXG4gIH0gZmluYWxseSB7XG4gICAgaWYgKGZkID49IDApIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNsb3NlU3luYyhmZClcbiAgICAgIH0gY2F0Y2gge1xuICAgICAgICAvKiBpZ25vcmUgKi9cbiAgICAgIH1cbiAgICB9XG4gICAgaWYgKCF3YXNSYXcpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIHR0eS5zZXRSYXdNb2RlPy4oZmFsc2UpXG4gICAgICB9IGNhdGNoIHtcbiAgICAgICAgLyogVFRZIG1heSBiZSBnb25lICovXG4gICAgICB9XG4gICAgfVxuICB9XG59XG4vKiBlc2xpbnQtZW5hYmxlIGN1c3RvbS1ydWxlcy9uby1zeW5jLWZzICovXG5cbmNvbnN0IENPTlNPTEVfU1RET1VUX01FVEhPRFMgPSBbXG4gICdsb2cnLFxuICAnaW5mbycsXG4gICdkZWJ1ZycsXG4gICdkaXInLFxuICAnZGlyeG1sJyxcbiAgJ2NvdW50JyxcbiAgJ2NvdW50UmVzZXQnLFxuICAnZ3JvdXAnLFxuICAnZ3JvdXBDb2xsYXBzZWQnLFxuICAnZ3JvdXBFbmQnLFxuICAndGFibGUnLFxuICAndGltZScsXG4gICd0aW1lRW5kJyxcbiAgJ3RpbWVMb2cnLFxuXSBhcyBjb25zdFxuY29uc3QgQ09OU09MRV9TVERFUlJfTUVUSE9EUyA9IFsnd2FybicsICdlcnJvcicsICd0cmFjZSddIGFzIGNvbnN0XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLFFBQVEsTUFBTSxXQUFXO0FBQ2hDLFNBQ0VDLFNBQVMsRUFDVEMsU0FBUyxJQUFJQyxXQUFXLEVBQ3hCQyxRQUFRLEVBQ1JDLFFBQVEsRUFDUkMsU0FBUyxRQUNKLElBQUk7QUFDWCxPQUFPQyxJQUFJLE1BQU0sbUJBQW1CO0FBQ3BDLE9BQU9DLFFBQVEsTUFBTSx1QkFBdUI7QUFDNUMsT0FBT0MsS0FBSyxJQUFJLEtBQUtDLFNBQVMsUUFBUSxPQUFPO0FBQzdDLGNBQWNDLFNBQVMsUUFBUSxrQkFBa0I7QUFDakQsU0FBU0MsY0FBYyxRQUFRLCtCQUErQjtBQUM5RCxTQUFTQyxNQUFNLFFBQVEsYUFBYTtBQUNwQyxTQUFTQyxvQkFBb0IsUUFBUSx3QkFBd0I7QUFDN0QsU0FBU0MsZUFBZSxRQUFRLG9DQUFvQztBQUNwRSxTQUFTQyxlQUFlLFFBQVEsb0JBQW9CO0FBQ3BELFNBQVNDLFFBQVEsUUFBUSxrQkFBa0I7QUFDM0MsU0FBU0MsTUFBTSxRQUFRLE1BQU07QUFDN0Is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QUMsRUFBRSxPQUFPO0lBQ2ZDLFVBQVUsQ0FBQyxFQUFFLENBQUNPLEdBQUcsRUFBRSxPQUFPLEVBQUUsR0FBRyxJQUFJO0VBQ3JDLENBQUM7RUFDRCxNQUFNNkgsTUFBTSxHQUFHRCxHQUFHLENBQUNwSSxLQUFLLEtBQUssSUFBSTtFQUNqQztFQUNBO0VBQ0E7RUFDQSxJQUFJc0ksRUFBRSxHQUFHLENBQUMsQ0FBQztFQUNYLElBQUk7SUFDRjtJQUNBO0lBQ0EsSUFBSSxDQUFDRCxNQUFNLEVBQUVELEdBQUcsQ0FBQ25JLFVBQVUsR0FBRyxJQUFJLENBQUM7SUFDbkNxSSxFQUFFLEdBQUczYyxRQUFRLENBQUMsVUFBVSxFQUFFRCxXQUFXLENBQUM2YyxRQUFRLEdBQUc3YyxXQUFXLENBQUM4YyxVQUFVLENBQUM7SUFDeEUsTUFBTUMsR0FBRyxHQUFHVCxNQUFNLENBQUNVLEtBQUssQ0FBQyxJQUFJLENBQUM7SUFDOUIsS0FBSyxJQUFJQyxDQUFDLEdBQUcsQ0FBQyxFQUFFQSxDQUFDLEdBQUcsRUFBRSxFQUFFQSxDQUFDLEVBQUUsRUFBRTtNQUMzQixJQUFJL2MsUUFBUSxDQUFDMGMsRUFBRSxFQUFFRyxHQUFHLEVBQUUsQ0FBQyxFQUFFQSxHQUFHLENBQUM5TCxNQUFNLEVBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxFQUFFO0lBQ25EO0VBQ0YsQ0FBQyxDQUFDLE1BQU07SUFDTjtJQUNBO0VBQUEsQ0FDRCxTQUFTO0lBQ1IsSUFBSTJMLEVBQUUsSUFBSSxDQUFDLEVBQUU7TUFDWCxJQUFJO1FBQ0Y5YyxTQUFTLENBQUM4YyxFQUFFLENBQUM7TUFDZixDQUFDLENBQUMsTUFBTTtRQUNOO01BQUE7SUFFSjtJQUNBLElBQUksQ0FBQ0QsTUFBTSxFQUFFO01BQ1gsSUFBSTtRQUNGRCxHQUFHLENBQUNuSSxVQUFVLEdBQUcsS0FBSyxDQUFDO01BQ3pCLENBQUMsQ0FBQyxNQUFNO1FBQ047TUFBQTtJQUVKO0VBQ0Y7QUFDRjtBQUNBOztBQUVBLE1BQU0rRyxzQkFBc0IsR0FBRyxDQUM3QixLQUFLLEVBQ0wsTUFBTSxFQUNOLE9BQU8sRUFDUCxLQUFLLEVBQ0wsUUFBUSxFQUNSLE9BQU8sRUFDUCxZQUFZLEVBQ1osT0FBTyxFQUNQLGdCQUFnQixFQUNoQixVQUFVLEVBQ1YsT0FBTyxFQUNQLE1BQU0sRUFDTixTQUFTLEVBQ1QsU0FBUyxDQUNWLElBQUl4VSxLQUFLO0FBQ1YsTUFBTXlVLHNCQUFzQixHQUFHLENBQUMsTUFBTSxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUMsSUFBSXpVLEtBQUsiLCJpZ25vcmVMaXN0IjpbXX0= 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 000000000..389384a8d --- /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 000000000..38f6dcb0f --- /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 000000000..871db1bc2 --- /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 000000000..fa84a4f81 --- /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 000000000..e18c7f848 --- /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 000000000..0791fbb8a --- /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 000000000..e4dc3dc7a --- /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 000000000..64124d6ec --- /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 000000000..1d81cdede --- /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 000000000..fe11e067e --- /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 000000000..a4fd3812c --- /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 000000000..ab417fcae --- /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 000000000..7c795d1f0 --- /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 000000000..7d50aedab --- /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 000000000..a4fff7cb5 --- /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 000000000..d9057725f --- /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 000000000..bee9f8f1c --- /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 000000000..ca89182d7 --- /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 000000000..27ace59a6 --- /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 000000000..5a9b9df22 --- /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 000000000..278c3fd63 --- /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 000000000..ccd8e4957 --- /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 000000000..edb26b3b6 --- /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 000000000..0b97ac151 --- /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 000000000..e5321f6e5 --- /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 000000000..16aed4a6c --- /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 000000000..9b6007b10 --- /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 000000000..ed6c60ab4 --- /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 000000000..80b1b80ef --- /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 000000000..8ac7d62b6 --- /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 000000000..e14db928c --- /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 000000000..138cfef29 --- /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 000000000..5d4fbe7ef --- /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 000000000..4548b923f --- /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 000000000..4e38d7d03 --- /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 000000000..49f222395 --- /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 000000000..0f58d6f20 --- /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 000000000..67a1f6b38 --- /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 000000000..40ba7e214 --- /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 000000000..4af1dc4ce --- /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 000000000..1fcde2bdb --- /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 000000000..016b4ecd2 --- /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 000000000..ac78cb6d5 --- /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 000000000..4d157bc2a --- /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 000000000..61b56dbf3 --- /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 000000000..95d66bf34 --- /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 000000000..a62a4bae1 --- /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 000000000..285a07ac1 --- /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 000000000..bdc841841 --- /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 000000000..7393f1baa --- /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 000000000..f3286197b --- /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 000000000..106555b13 --- /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 000000000..7ce9e8758 --- /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 000000000..6f9dfaf92 --- /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 000000000..369763eee --- /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 000000000..ab57ecf72 --- /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 000000000..7be1950b1 --- /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 000000000..f9f5df1c8 --- /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 000000000..8cb79c0cc --- /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 91f45eabf..56517645d 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 5882ab8c7..64403c297 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 f2e8d95ce..60e4ed16a 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 8b8b30894..5bf70b0a5 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 717a1a798..49c050cce 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 cc9f74388..05c97665c 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 7bfe7227a..2bf578eb4 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 41c033500..27a837794 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 f5deb9c49..5b3ad2203 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 f4f5130ee..b2b8c3d59 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 3f719f4e1..ca52ec91c 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 a70012711..50054e90d 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 7f835c0cd..c0299ccc1 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 000000000..db77c9f2a --- /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 000000000..a0a8b410d --- /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 b7817e13a..67a50d6a7 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/849] 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 dfaaf99cd..facc8f3c5 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 a491edfaa..17f929eb9 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 20b72968a..6c4b19830 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 03a32b823..9ee71564e 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 5065c37d6..d7b6e4ae2 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 b2b8c3d59..bcee8b7a7 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 88418d280..fb4294318 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/849] 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 5f50ab630..f24a5baf7 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 ec79588fe..a77ca0083 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 5065c37d6..0b82b2fdf 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 425bfa4d5..429996db7 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 0c87b2cc7..eab296926 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/849] 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 b51962113..137a5de08 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 5f50ab630..ab60f3a0b 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 d7b6e4ae2..cfe7b7d21 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 5bf70b0a5..b32f03cd7 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 bcee8b7a7..9ec53ddc0 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 0c87b2cc7..164123244 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/849] 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 ad530ea1d..305b79526 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 5b0ea3268..a077dc2d4 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 a77ca0083..ec79588fe 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/849] 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 577aa67a7..f4cf321f0 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 1d4ff429a..96f7e145b 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 c93554b99..000000000 --- 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 7013dfdb6..14a5d108d 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 ec79588fe..04c276797 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 2fc6271f8..e6e10ec06 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 000000000..943ff76bc --- /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 1c23959a3..6536bddb0 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 be929ce6c..758fef307 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 000000000..4fb5866d1 --- /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 6741a24f9..8e2349131 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 000000000..d9fd98dee --- /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 e37eca558..de0d750c3 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 757f7789b..bb1860817 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 13ec46995..68ba67ea5 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 521cd5751..99dfc2d88 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 72c94fa11..71c491455 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 54dfa50fa..4010dc9ff 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 e3da69852..79078189e 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 2c0b2f0fe..b5bd8f253 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 e7b55e71d..bed421234 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 02860485a..e5f1acdd6 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 f69d338c1..ea2a74c9a 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 000000000..73b0c9448 --- /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 000000000..38a88f317 --- /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 000000000..b2627bb29 --- /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 e0163f506..96898cee3 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 2be8a7d7c..5fdce3bf9 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 bee9f8f1c..57272bd36 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 ab57ecf72..87025ed0f 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 c32692210..35f1448a1 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 429996db7..d37f86f71 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 b41a0b27e..ff4f08b00 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 db77c9f2a..7c8a8a724 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/849] 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 55b6a272b..d43f6d56f 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 64403c297..8d5cf888f 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 fb4294318..461fbc8b0 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/849] 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 8d1a10000..48bbdbf0d 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 9ffa809a5..c8f284228 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/849] 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 48bbdbf0d..f147ed10b 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 a077dc2d4..93973019f 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 5a3eac5e8..fb26d9b5e 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/849] 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 f147ed10b..c9c2471dd 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/849] 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 4ae35d36c..14865fca1 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 8d58df25c..1121211d9 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 c32692210..6fe380cb9 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 b41a0b27e..cb64c4284 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/849] 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 b32f03cd7..a05c9fc0a 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 7c8a8a724..81faab32e 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/849] 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 ee317662a..6280041ca 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 461fbc8b0..3c5ccbcc7 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/849] 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 a287dadf9..8bef611b0 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/849] 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 6280041ca..45feafff5 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 60e4ed16a..c9e110099 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 a05c9fc0a..07a8fbd5a 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 05c97665c..69a2bb8a8 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 ec87ec4f3..6378a55c4 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 9507f41ca..ddffc566c 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/849] 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 45feafff5..bdb1c8279 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 8d5cf888f..6a5922773 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 c9e110099..f159cc681 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 69a2bb8a8..4e546f3d8 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 3c5ccbcc7..82a87c91f 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/849] 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 1121211d9..f5b3ad73a 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 bdb1c8279..0cf63fd53 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 9ec53ddc0..a5f876da1 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 82a87c91f..30dcd67e3 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/849] 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 0cf63fd53..e0ccff15b 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 07a8fbd5a..2ab0e2272 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 6378a55c4..385dd5f48 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 a5f876da1..4e609f5e6 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 59fc63928..9f1c48771 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 30dcd67e3..9bed6c3c1 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 ddffc566c..8f24ba7ac 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/849] 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 e0ccff15b..a5a0d1296 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 4e609f5e6..c16b9fd65 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/849] 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 c16b9fd65..4a1f4c720 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/849] 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 a5a0d1296..e70644ea6 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 4a1f4c720..1ed03f810 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/849] 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 9992cd340..0f4f14e3e 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 e70644ea6..76dd78b54 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/849] 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 76dd78b54..e2df73a2e 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 000000000..1a3800ec7 --- /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 9bed6c3c1..b38b8fbd2 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/849] 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 924ce2d17..f39d9d0b2 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/849] 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 000000000..7980093a9 --- /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 e2df73a2e..fdb7f24e5 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 27a837794..b97c6dd7a 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 000000000..502aab8fb --- /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/849] 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 40b4da569..4c2b95b42 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 f5b3ad73a..e74313178 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 d43f6d56f..904e44ec2 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 fdb7f24e5..80a4ea4a4 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 fb26d9b5e..40bd77763 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 b38b8fbd2..1841bdd77 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 8f24ba7ac..784b69015 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/849] 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 988983dba..a257de48b 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 857776983..18b62bb48 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 af1d89ae8..be08ca034 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 137a5de08..9ef4398e9 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 e74313178..f90b881e8 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 80a4ea4a4..e9152f1b3 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/849] 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 6a5922773..3ba0114ab 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/849] 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 2bf578eb4..c7ae29e24 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/849] 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 efeccf5cf..4312a6b54 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 ebd13f54b..3d1f37035 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 18b62bb48..964e1b522 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 be08ca034..3b83b81da 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 9ef4398e9..bee0d8811 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 f90b881e8..86f3617e2 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 fc8abe696..c5323f872 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 e9152f1b3..6e69ba42c 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 000000000..54e6733f8 --- /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 385dd5f48..edc586e93 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 9f1c48771..9e7cac999 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 40bd77763..2c98c64e0 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 50054e90d..1c74872c1 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 1841bdd77..9c67b1e37 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 784b69015..e8d94e64c 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/849] 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 e623700d8..964311fb7 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 9284ba28e..a9667528d 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 86f3617e2..78cac4f88 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 0f4f14e3e..19d162e6d 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 7ce9e8758..523a43102 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 6e69ba42c..af8f60786 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 2ab0e2272..0403135de 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 ca5b93485..000000000 --- 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 edc586e93..8df818811 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 1ed03f810..b24766ab3 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 9e7cac999..9e8cb5a2b 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 2c98c64e0..a35f3c417 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 000000000..877fde5d7 --- /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 e8d94e64c..aac00d667 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 81faab32e..d6ecb7f61 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/849] 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 fb4423220..a2c797fd4 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/849] 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 af8f60786..b6cb03cb3 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/849] 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 904e44ec2..181b96b43 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 b6cb03cb3..c329057c7 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 9c67b1e37..ba1880ed3 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/849] 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 03ae3b57e..e7c164818 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/849] 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 e7c164818..13d00834e 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/849] 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 13d00834e..86f5c7a2e 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 6b3001a85..d144656b3 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/849] 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 d288d28ba..3a0381a72 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 68ba67ea5..408d23c22 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 42d59d035..1750dbeee 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 000000000..d42839b5f --- /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 f0d9a3179..c23ce34fe 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 96898cee3..ff2507ac6 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 86f5c7a2e..ca6583005 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 e6e23cc45..cfb91b059 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 418ee0c54..005d8cc4c 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 868a35c4a..4f91a386b 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 55ce76b37285ea2e86f3c4529f0c688143e6b02e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:10:18 -0700 Subject: [PATCH 106/849] feat: add architecture-diagram skill (Cocoon AI port) (#9906) Port of Cocoon AI's architecture-diagram-generator (MIT) as a Hermes skill. Generates professional dark-themed system architecture diagrams as standalone HTML/SVG files. Self-contained output, no dependencies. - SKILL.md with design system specs, color palette, layout rules - HTML template with all component types, arrow styles, legend examples - Fits alongside excalidraw in creative/ category Source: https://github.com/Cocoon-AI/architecture-diagram-generator --- skills/creative/architecture-diagram/SKILL.md | 129 +++++++ .../templates/template.html | 319 ++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 skills/creative/architecture-diagram/SKILL.md create mode 100644 skills/creative/architecture-diagram/templates/template.html diff --git a/skills/creative/architecture-diagram/SKILL.md b/skills/creative/architecture-diagram/SKILL.md new file mode 100644 index 000000000..aa95b76ea --- /dev/null +++ b/skills/creative/architecture-diagram/SKILL.md @@ -0,0 +1,129 @@ +--- +name: architecture-diagram +description: Generate professional dark-themed system architecture diagrams as standalone HTML/SVG files. Self-contained output with no external dependencies. Based on Cocoon AI's architecture-diagram-generator (MIT). +version: 1.0.0 +author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent +license: MIT +dependencies: [] +metadata: + hermes: + tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud] + related_skills: [excalidraw] +--- + +# Architecture Diagram Skill + +Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser. + +Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT). + +## Workflow + +1. User describes their system architecture (components, connections, technologies) +2. Generate the HTML file following the design system below +3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) +4. User opens in any browser — works offline, no dependencies + +### Output Location + +Save diagrams to a user-specified path, or default to the current working directory: +``` +./[project-name]-architecture.html +``` + +### Preview + +After saving, suggest the user open it: +```bash +# macOS +open ./my-architecture.html +# Linux +xdg-open ./my-architecture.html +``` + +## Design System & Visual Language + +### Color Palette (Semantic Mapping) + +Use specific `rgba` fills and hex strokes to categorize components: + +| Component Type | Fill (rgba) | Stroke (Hex) | +| :--- | :--- | :--- | +| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) | +| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) | +| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) | +| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) | +| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) | +| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) | +| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) | + +### Typography & Background +- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts +- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels) +- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern + +```svg + + + + +``` + +## Technical Implementation Details + +### Component Rendering +Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**: +1. Draw an opaque background rect (`#0f172a`) +2. Draw the semi-transparent styled rect on top + +### Connection Rules +- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes +- **Arrowheads:** Defined via SVG markers +- **Security Flows:** Use dashed lines in rose color (`#fb7185`) +- **Boundaries:** + - *Security Groups:* Dashed (`4,4`), rose color + - *Regions:* Large dashed (`8,4`), amber color, `rx="12"` + +### Spacing & Layout Logic +- **Standard Height:** 60px (Services); 80-120px (Large components) +- **Vertical Gap:** Minimum 40px between components +- **Message Buses:** Must be placed *in the gap* between services, not overlapping them +- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it. + +## Document Structure + +The generated HTML file follows a four-part layout: +1. **Header:** Title with a pulsing dot indicator and subtitle +2. **Main SVG:** The diagram contained within a rounded border card +3. **Summary Cards:** A grid of three cards below the diagram for high-level details +4. **Footer:** Minimal metadata + +### Info Card Pattern +```html +
+
+
+

Title

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
+
+``` + +## Output Requirements +- **Single File:** One self-contained `.html` file +- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts) +- **No JavaScript:** Use pure CSS for any animations (like pulsing dots) +- **Compatibility:** Must render correctly in any modern web browser + +## Template Reference + +Load the full HTML template for the exact structure, CSS, and SVG component examples: + +``` +skill_view(name="architecture-diagram", file_path="templates/template.html") +``` + +The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams. diff --git a/skills/creative/architecture-diagram/templates/template.html b/skills/creative/architecture-diagram/templates/template.html new file mode 100644 index 000000000..f5b32fbe7 --- /dev/null +++ b/skills/creative/architecture-diagram/templates/template.html @@ -0,0 +1,319 @@ + + + + + + [PROJECT NAME] Architecture Diagram + + + + +
+ +
+
+
+

[PROJECT NAME] Architecture

+
+

[Subtitle description]

+
+ + +
+ + + + + + + + + + + + + + + + + + + Users + Browser/Mobile + + + + Auth Provider + OAuth 2.0 + + + + AWS Region: us-west-2 + + + + CloudFront + CDN + + + + S3 Buckets + • bucket-one + • bucket-two + • bucket-three + OAI Protected + + + + sg-name :port + + + + Load Balancer + HTTPS :443 + + + + API Server + FastAPI :8000 + + + + Database + PostgreSQL + + + + Frontend + React + TypeScript + Additional detail + More info + domain.example.com + + + + + + HTTPS + + + + + + + OAI + + + + + TLS + + + + JWT + PKCE + + + Legend + + + Frontend + + + Backend + + + Cloud Service + + + Database + + + Security + + + Auth Flow + + + Security Group + +
+ + +
+
+
+
+

Card Title 1

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+ +
+
+
+

Card Title 2

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+ +
+
+
+

Card Title 3

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+
+ + + +
+ + From 1e5e1e822bc7bbf2a9bdefe12384745dc8730c23 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:11:37 -0700 Subject: [PATCH 107/849] fix: ESC cancels secret/sudo prompts, clearer skip messaging (#9902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ESC key binding (eager) for secret_state and sudo_state modal prompts — fires immediately, same behavior as Ctrl+C cancel - Update placeholder text: 'Enter to submit · ESC to skip' (was 'Enter to skip' which was confusing — Enter on empty looked like submitting nothing rather than intentionally skipping) - Update widget body text: 'ESC or Ctrl+C to skip' - Change feedback message from 'Secret entry cancelled' to 'Secret entry skipped' — more accurate for the action taken - getpass fallback prompt also updated for non-TUI mode --- cli.py | 24 +++++++++++++++++++++--- hermes_cli/callbacks.py | 6 +++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index 970c98b06..ebc8b7637 100644 --- a/cli.py +++ b/cli.py @@ -8631,6 +8631,24 @@ class HermesCLI: self._should_exit = True event.app.exit() + _modal_prompt_active = Condition( + lambda: bool(self._secret_state or self._sudo_state) + ) + + @kb.add('escape', filter=_modal_prompt_active, eager=True) + def handle_escape_modal(event): + """ESC cancels active secret/sudo prompts.""" + if self._secret_state: + self._cancel_secret_capture() + event.app.current_buffer.reset() + event.app.invalidate() + return + if self._sudo_state: + self._sudo_state["response_queue"].put("") + self._sudo_state = None + event.app.invalidate() + return + @kb.add('c-z') def handle_ctrl_z(event): """Handle Ctrl+Z - suspend process to background (Unix only).""" @@ -8928,9 +8946,9 @@ class HermesCLI: if cli_ref._voice_processing: return "transcribing..." if cli_ref._sudo_state: - return "type password (hidden), Enter to skip" + return "type password (hidden), Enter to submit · ESC to skip" if cli_ref._secret_state: - return "type secret (hidden), Enter to skip" + return "type secret (hidden), Enter to submit · ESC to skip" if cli_ref._approval_state: return "" if cli_ref._clarify_freetext: @@ -9173,7 +9191,7 @@ class HermesCLI: prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}" metadata = state.get("metadata") or {} help_text = metadata.get("help") - body = 'Enter secret below (hidden), or press Enter to skip' + body = 'Enter secret below (hidden), ESC or Ctrl+C to skip' content_lines = [prompt, body] if help_text: content_lines.insert(1, str(help_text)) diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index 724e6e4c8..fa40eced5 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -75,12 +75,12 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: if not hasattr(cli, "_secret_deadline"): cli._secret_deadline = 0 try: - value = getpass.getpass(f"{prompt} (hidden, Enter to skip): ") + value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ") except (EOFError, KeyboardInterrupt): value = "" if not value: - cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}") + cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}") return { "success": True, "reason": "cancelled", @@ -133,7 +133,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: cli._app.invalidate() if not value: - cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}") + cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}") return { "success": True, "reason": "cancelled", From 3bc661ea292d4a574f8d76ec01d01cb89131f8cf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 18:26:00 -0500 Subject: [PATCH 108/849] 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 7c795d1f0..5107f41d9 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 ca6583005..703f33ec2 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 6448e1da23e938e5ef5672defc688777fbe5ef11 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:26:01 -0700 Subject: [PATCH 109/849] feat(zai): add GLM-5V-Turbo support for coding plan (#9907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add glm-5v-turbo to OpenRouter, Nous, and native Z.AI model lists - Add glm-5v context length entry (200K tokens) to model metadata - Update Z.AI endpoint probe to try multiple candidate models per endpoint (glm-5.1, glm-5v-turbo, glm-4.7) — fixes detection for newer coding plan accounts that lack older models - Add zai to _PROVIDER_VISION_MODELS so auxiliary vision tasks (vision_analyze, browser screenshots) route through 5v Fixes #9888 --- agent/auxiliary_client.py | 1 + hermes_cli/auth.py | 71 +++++++++++++++++++++------------------ hermes_cli/models.py | 3 ++ 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 49dea65f9..4d2331548 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -112,6 +112,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = { # "exotic provider" branch checks this before falling back to the main model. _PROVIDER_VISION_MODELS: Dict[str, str] = { "xiaomi": "mimo-v2-omni", + "zai": "glm-5v-turbo", } # OpenRouter app attribution headers diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e63a1ebb6..636416a97 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -383,13 +383,16 @@ def _resolve_api_key_provider_secret( # Z.AI has separate billing for general vs coding plans, and global vs China # endpoints. A key that works on one may return "Insufficient balance" on # another. We probe at setup time and store the working endpoint. +# Each entry lists candidate models to try in order — newer coding plan accounts +# may only have access to recent models (glm-5.1, glm-5v-turbo) while older +# ones still use glm-4.7. ZAI_ENDPOINTS = [ - # (id, base_url, default_model, label) - ("global", "https://api.z.ai/api/paas/v4", "glm-5", "Global"), - ("cn", "https://open.bigmodel.cn/api/paas/v4", "glm-5", "China"), - ("coding-global", "https://api.z.ai/api/coding/paas/v4", "glm-4.7", "Global (Coding Plan)"), - ("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", "glm-4.7", "China (Coding Plan)"), + # (id, base_url, probe_models, label) + ("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"), + ("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"), + ("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"), + ("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"), ] @@ -397,35 +400,37 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str """Probe z.ai endpoints to find one that accepts this API key. Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the - first working endpoint, or None if all fail. + first working endpoint, or None if all fail. For endpoints with multiple + candidate models, tries each in order and returns the first that succeeds. """ - for ep_id, base_url, model, label in ZAI_ENDPOINTS: - try: - resp = httpx.post( - f"{base_url}/chat/completions", - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json={ - "model": model, - "stream": False, - "max_tokens": 1, - "messages": [{"role": "user", "content": "ping"}], - }, - timeout=timeout, - ) - if resp.status_code == 200: - logger.debug("Z.AI endpoint probe: %s (%s) OK", ep_id, base_url) - return { - "id": ep_id, - "base_url": base_url, - "model": model, - "label": label, - } - logger.debug("Z.AI endpoint probe: %s returned %s", ep_id, resp.status_code) - except Exception as exc: - logger.debug("Z.AI endpoint probe: %s failed: %s", ep_id, exc) + for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS: + for model in probe_models: + try: + resp = httpx.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "stream": False, + "max_tokens": 1, + "messages": [{"role": "user", "content": "ping"}], + }, + timeout=timeout, + ) + if resp.status_code == 200: + logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model) + return { + "id": ep_id, + "base_url": base_url, + "model": model, + "label": label, + } + logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code) + except Exception as exc: + logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc) return None diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 852601229..18f29c6cd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -44,6 +44,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("minimax/minimax-m2.7", ""), ("minimax/minimax-m2.5", ""), ("z-ai/glm-5.1", ""), + ("z-ai/glm-5v-turbo", ""), ("z-ai/glm-5-turbo", ""), ("moonshotai/kimi-k2.5", ""), ("x-ai/grok-4.20", ""), @@ -89,6 +90,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "minimax/minimax-m2.7", "minimax/minimax-m2.5", "z-ai/glm-5.1", + "z-ai/glm-5v-turbo", "z-ai/glm-5-turbo", "moonshotai/kimi-k2.5", "x-ai/grok-4.20-beta", @@ -134,6 +136,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "zai": [ "glm-5.1", "glm-5", + "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5", From 039023f49747599199c8aee26a7a73b3640f9b6a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:26:36 -0700 Subject: [PATCH 110/849] diag: log all hermes processes on unexpected gateway shutdown (#9905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway receives SIGTERM/SIGINT, the shutdown handler now runs 'ps aux' and logs every hermes/gateway-related process (excluding itself). This will show in agent.log as: WARNING: Shutdown diagnostic — other hermes processes running: hermes 1234 ... hermes update --gateway hermes 5678 ... hermes gateway restart This is the missing diagnostic for #5646 / #6666 — we can prove the restarts are from systemctl but can't determine WHO issues the systemctl command. Next time it happens, the agent.log will contain the evidence (the process that sent the signal or called systemctl should still be alive when the handler fires). --- gateway/run.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index da3560cf7..5c3e5f13c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9273,6 +9273,29 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = nonlocal _signal_initiated_shutdown _signal_initiated_shutdown = True logger.info("Received SIGTERM/SIGINT — initiating shutdown") + # Diagnostic: log all hermes-related processes so we can identify + # what triggered the signal (hermes update, hermes gateway restart, + # a stale detached subprocess, etc.). + try: + import subprocess as _sp + _ps = _sp.run( + ["ps", "aux"], + capture_output=True, text=True, timeout=3, + ) + _hermes_procs = [ + line for line in _ps.stdout.splitlines() + if ("hermes" in line.lower() or "gateway" in line.lower()) + and str(os.getpid()) not in line.split()[1:2] # exclude self + ] + if _hermes_procs: + logger.warning( + "Shutdown diagnostic — other hermes processes running:\n %s", + "\n ".join(_hermes_procs), + ) + else: + logger.info("Shutdown diagnostic — no other hermes processes found") + except Exception: + pass asyncio.create_task(runner.stop()) def restart_signal_handler(): From 10494b42a1b012bbfa2e18c0d665af90a57531c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:27:02 -0700 Subject: [PATCH 111/849] feat(discord): register skills under /skill command group with category subcommands (#9909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of consuming one top-level slash command slot per skill (hitting the 100-command limit with ~26 built-ins + 74 skills), skills are now organized under a single /skill group command with category-based subcommand groups: /skill creative ascii-art [args] /skill media gif-search [args] /skill mlops axolotl [args] Discord supports 25 subcommand groups × 25 subcommands = 625 max skills, well beyond the previous 74-slot ceiling. Categories are derived from the skill directory structure: - skills/creative/ascii-art/ → category 'creative' - skills/mlops/training/axolotl/ → category 'mlops' (top-level parent) - skills/dogfood/ → uncategorized (direct subcommand) Changes: - hermes_cli/commands.py: add discord_skill_commands_by_category() with category grouping, hub/disabled filtering, Discord limit enforcement - gateway/platforms/discord.py: replace top-level skill registration with _register_skill_group() using app_commands.Group hierarchy - tests: 7 new tests covering group creation, category grouping, uncategorized skills, hub exclusion, deep nesting, empty skills, and handler dispatch Inspired by Discord community suggestion from bottium. --- gateway/platforms/discord.py | 94 +++++++++--- hermes_cli/commands.py | 110 ++++++++++++++ tests/gateway/test_discord_slash_commands.py | 106 +++++++++++++ tests/hermes_cli/test_commands.py | 151 +++++++++++++++++++ 4 files changed, 436 insertions(+), 25 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 0adee9eb6..51ef0a868 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1736,46 +1736,90 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_btw(interaction: discord.Interaction, question: str): await self._run_simple_slash(interaction, f"/btw {question}") - # Register installed skills as native slash commands (parity with - # Telegram, which uses telegram_menu_commands() in commands.py). - # Discord allows up to 100 application commands globally. - _DISCORD_CMD_LIMIT = 100 + # Register skills under a single /skill command group with category + # subcommand groups. This uses 1 top-level slot instead of N, + # supporting up to 25 categories × 25 skills = 625 skills. + self._register_skill_group(tree) + + def _register_skill_group(self, tree) -> None: + """Register a ``/skill`` command group with category subcommand groups. + + Skills are organized by their directory category under ``SKILLS_DIR``. + Each category becomes a subcommand group; root-level skills become + direct subcommands. Discord supports 25 subcommand groups × 25 + subcommands each = 625 skills — well beyond the old 100-command cap. + """ try: - from hermes_cli.commands import discord_skill_commands + from hermes_cli.commands import discord_skill_commands_by_category - existing_names = {cmd.name for cmd in tree.get_commands()} - remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names)) + existing_names = set() + try: + existing_names = {cmd.name for cmd in tree.get_commands()} + except Exception: + pass - skill_entries, skipped = discord_skill_commands( - max_slots=remaining_slots, + categories, uncategorized, hidden = discord_skill_commands_by_category( reserved_names=existing_names, ) - for discord_name, description, cmd_key in skill_entries: - # Closure factory to capture cmd_key per iteration - def _make_skill_handler(_key: str): - async def _skill_slash(interaction: discord.Interaction, args: str = ""): - await self._run_simple_slash(interaction, f"{_key} {args}".strip()) - return _skill_slash + if not categories and not uncategorized: + return - handler = _make_skill_handler(cmd_key) - handler.__name__ = f"skill_{discord_name.replace('-', '_')}" + skill_group = discord.app_commands.Group( + name="skill", + description="Run a Hermes skill", + ) + # ── Helper: build a callback for a skill command key ── + def _make_handler(_key: str): + @discord.app_commands.describe(args="Optional arguments for the skill") + async def _handler(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash(interaction, f"{_key} {args}".strip()) + _handler.__name__ = f"skill_{_key.lstrip('/').replace('-', '_')}" + return _handler + + # ── Uncategorized (root-level) skills → direct subcommands ── + for discord_name, description, cmd_key in uncategorized: cmd = discord.app_commands.Command( name=discord_name, - description=description, - callback=handler, + description=description or f"Run the {discord_name} skill", + callback=_make_handler(cmd_key), ) - discord.app_commands.describe(args="Optional arguments for the skill")(cmd) - tree.add_command(cmd) + skill_group.add_command(cmd) - if skipped: + # ── Category subcommand groups ── + for cat_name in sorted(categories): + cat_desc = f"{cat_name.replace('-', ' ').title()} skills" + if len(cat_desc) > 100: + cat_desc = cat_desc[:97] + "..." + cat_group = discord.app_commands.Group( + name=cat_name, + description=cat_desc, + parent=skill_group, + ) + for discord_name, description, cmd_key in categories[cat_name]: + cmd = discord.app_commands.Command( + name=discord_name, + description=description or f"Run the {discord_name} skill", + callback=_make_handler(cmd_key), + ) + cat_group.add_command(cmd) + + tree.add_command(skill_group) + + total = sum(len(v) for v in categories.values()) + len(uncategorized) + logger.info( + "[%s] Registered /skill group: %d skill(s) across %d categories" + " + %d uncategorized", + self.name, total, len(categories), len(uncategorized), + ) + if hidden: logger.warning( - "[%s] Discord slash command limit reached (%d): %d skill(s) not registered", - self.name, _DISCORD_CMD_LIMIT, skipped, + "[%s] %d skill(s) not registered (Discord subcommand limits)", + self.name, hidden, ) except Exception as exc: - logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc) + logger.warning("[%s] Failed to register /skill group: %s", self.name, exc) def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent: """Build a MessageEvent from a Discord slash command interaction.""" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e62c7e610..e08aacf64 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -582,6 +582,116 @@ def discord_skill_commands( ) +def discord_skill_commands_by_category( + reserved_names: set[str], +) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]: + """Return skill entries organized by category for Discord ``/skill`` subcommand groups. + + Skills whose directory is nested at least 2 levels under ``SKILLS_DIR`` + (e.g. ``creative/ascii-art/SKILL.md``) are grouped by their top-level + category. Root-level skills (e.g. ``dogfood/SKILL.md``) are returned as + *uncategorized* — the caller should register them as direct subcommands + of the ``/skill`` group. + + The same filtering as :func:`discord_skill_commands` is applied: hub + skills excluded, per-platform disabled excluded, names clamped. + + Returns: + ``(categories, uncategorized, hidden_count)`` + + - *categories*: ``{category_name: [(name, description, cmd_key), ...]}`` + - *uncategorized*: ``[(name, description, cmd_key), ...]`` + - *hidden_count*: skills dropped due to Discord group limits + (25 subcommand groups, 25 subcommands per group) + """ + from pathlib import Path as _P + + _platform_disabled: set[str] = set() + try: + from agent.skill_utils import get_disabled_skill_names + _platform_disabled = get_disabled_skill_names(platform="discord") + except Exception: + pass + + # Collect raw skill data -------------------------------------------------- + categories: dict[str, list[tuple[str, str, str]]] = {} + uncategorized: list[tuple[str, str, str]] = [] + _names_used: set[str] = set(reserved_names) + hidden = 0 + + try: + from agent.skill_commands import get_skill_commands + from tools.skills_tool import SKILLS_DIR + _skills_dir = SKILLS_DIR.resolve() + _hub_dir = (SKILLS_DIR / ".hub").resolve() + skill_cmds = get_skill_commands() + + for cmd_key in sorted(skill_cmds): + info = skill_cmds[cmd_key] + skill_path = info.get("skill_md_path", "") + if not skill_path: + continue + sp = _P(skill_path).resolve() + # Skip skills outside SKILLS_DIR or from the hub + if not str(sp).startswith(str(_skills_dir)): + continue + if str(sp).startswith(str(_hub_dir)): + continue + + skill_name = info.get("name", "") + if skill_name in _platform_disabled: + continue + + raw_name = cmd_key.lstrip("/") + # Clamp to 32 chars (Discord limit) + discord_name = raw_name[:32] + if discord_name in _names_used: + continue + _names_used.add(discord_name) + + desc = info.get("description", "") + if len(desc) > 100: + desc = desc[:97] + "..." + + # Determine category from the relative path within SKILLS_DIR. + # e.g. creative/ascii-art/SKILL.md → parts = ("creative", "ascii-art") + try: + rel = sp.parent.relative_to(_skills_dir) + except ValueError: + continue + parts = rel.parts + if len(parts) >= 2: + cat = parts[0] + categories.setdefault(cat, []).append((discord_name, desc, cmd_key)) + else: + uncategorized.append((discord_name, desc, cmd_key)) + except Exception: + pass + + # Enforce Discord limits: 25 subcommand groups, 25 subcommands each ------ + _MAX_GROUPS = 25 + _MAX_PER_GROUP = 25 + + trimmed_categories: dict[str, list[tuple[str, str, str]]] = {} + group_count = 0 + for cat in sorted(categories): + if group_count >= _MAX_GROUPS: + hidden += len(categories[cat]) + continue + entries = categories[cat][:_MAX_PER_GROUP] + hidden += max(0, len(categories[cat]) - _MAX_PER_GROUP) + trimmed_categories[cat] = entries + group_count += 1 + + # Uncategorized skills also count against the 25 top-level limit + remaining_slots = _MAX_GROUPS - group_count + if len(uncategorized) > remaining_slots: + hidden += len(uncategorized) - remaining_slots + uncategorized = uncategorized[:remaining_slots] + + return trimmed_categories, uncategorized, hidden + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index f7ed64639..b7967c69a 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -19,10 +19,34 @@ def _ensure_discord_mock(): discord_mod.Thread = type("Thread", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.Interaction = object + + # Lightweight mock for app_commands.Group and Command used by + # _register_skill_group. + class _FakeGroup: + def __init__(self, *, name, description, parent=None): + self.name = name + self.description = description + self.parent = parent + self._children: dict[str, object] = {} + if parent is not None: + parent.add_command(self) + + def add_command(self, cmd): + self._children[cmd.name] = cmd + + class _FakeCommand: + def __init__(self, *, name, description, callback, parent=None): + self.name = name + self.description = description + self.callback = callback + self.parent = parent + discord_mod.app_commands = SimpleNamespace( describe=lambda **kwargs: (lambda fn: fn), choices=lambda **kwargs: (lambda fn: fn), Choice=lambda **kwargs: SimpleNamespace(**kwargs), + Group=_FakeGroup, + Command=_FakeCommand, ) ext_mod = MagicMock() @@ -51,6 +75,12 @@ class FakeTree: return decorator + def add_command(self, cmd): + self.commands[cmd.name] = cmd + + def get_commands(self): + return [SimpleNamespace(name=n) for n in self.commands] + @pytest.fixture def adapter(): @@ -498,3 +528,79 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path): import os assert os.getenv("DISCORD_AUTO_THREAD") == "true" + + +# ------------------------------------------------------------------ +# /skill group registration +# ------------------------------------------------------------------ + + +def test_register_skill_group_creates_group(adapter): + """_register_skill_group should register a '/skill' Group on the tree.""" + mock_categories = { + "creative": [ + ("ascii-art", "Generate ASCII art", "/ascii-art"), + ("excalidraw", "Hand-drawn diagrams", "/excalidraw"), + ], + "media": [ + ("gif-search", "Search for GIFs", "/gif-search"), + ], + } + mock_uncategorized = [ + ("dogfood", "Exploratory QA testing", "/dogfood"), + ] + + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=(mock_categories, mock_uncategorized, 0), + ): + adapter._register_slash_commands() + + tree = adapter._client.tree + assert "skill" in tree.commands, "Expected /skill group to be registered" + skill_group = tree.commands["skill"] + assert skill_group.name == "skill" + # Should have 2 category subgroups + 1 uncategorized subcommand + children = skill_group._children + assert "creative" in children + assert "media" in children + assert "dogfood" in children + # Category groups should have their skills + assert "ascii-art" in children["creative"]._children + assert "excalidraw" in children["creative"]._children + assert "gif-search" in children["media"]._children + + +def test_register_skill_group_empty_skills_no_group(adapter): + """No /skill group should be added when there are zero skills.""" + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=({}, [], 0), + ): + adapter._register_slash_commands() + + tree = adapter._client.tree + assert "skill" not in tree.commands + + +def test_register_skill_group_handler_dispatches_command(adapter): + """Skill subcommand handlers should dispatch the correct /cmd-key text.""" + mock_categories = { + "media": [ + ("gif-search", "Search for GIFs", "/gif-search"), + ], + } + + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=(mock_categories, [], 0), + ): + adapter._register_slash_commands() + + skill_group = adapter._client.tree.commands["skill"] + media_group = skill_group._children["media"] + gif_cmd = media_group._children["gif-search"] + assert gif_cmd.callback is not None + # The callback name should reflect the skill + assert "gif_search" in gif_cmd.callback.__name__ + diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 30c2f22c2..5912194b5 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1028,3 +1028,154 @@ class TestDiscordSkillCommands: assert len(name) <= _CMD_NAME_LIMIT, ( f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})" ) + + +# --------------------------------------------------------------------------- +# Discord skill commands grouped by category +# --------------------------------------------------------------------------- + +from hermes_cli.commands import discord_skill_commands_by_category # noqa: E402 + + +class TestDiscordSkillCommandsByCategory: + """Tests for discord_skill_commands_by_category() — /skill group registration.""" + + def test_groups_skills_by_category(self, tmp_path, monkeypatch): + """Skills nested 2+ levels deep should be grouped by top-level category.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + # Create the directory structure so resolve() works + for p in [ + "skills/creative/ascii-art", + "skills/creative/excalidraw", + "skills/media/gif-search", + ]: + (tmp_path / p).mkdir(parents=True, exist_ok=True) + (tmp_path / p / "SKILL.md").write_text("---\nname: test\n---\n") + + fake_cmds = { + "/ascii-art": { + "name": "ascii-art", + "description": "Generate ASCII art", + "skill_md_path": f"{fake_skills_dir}/creative/ascii-art/SKILL.md", + }, + "/excalidraw": { + "name": "excalidraw", + "description": "Hand-drawn diagrams", + "skill_md_path": f"{fake_skills_dir}/creative/excalidraw/SKILL.md", + }, + "/gif-search": { + "name": "gif-search", + "description": "Search for GIFs", + "skill_md_path": f"{fake_skills_dir}/media/gif-search/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert "creative" in categories + assert "media" in categories + assert len(categories["creative"]) == 2 + assert len(categories["media"]) == 1 + assert uncategorized == [] + assert hidden == 0 + + def test_root_level_skills_are_uncategorized(self, tmp_path, monkeypatch): + """Skills directly under SKILLS_DIR (only 1 path component) → uncategorized.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / "dogfood").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "dogfood" / "SKILL.md").write_text("") + + fake_cmds = { + "/dogfood": { + "name": "dogfood", + "description": "QA testing", + "skill_md_path": f"{fake_skills_dir}/dogfood/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert categories == {} + assert len(uncategorized) == 1 + assert uncategorized[0][0] == "dogfood" + + def test_hub_skills_excluded(self, tmp_path, monkeypatch): + """Skills under .hub should be excluded.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / ".hub" / "some-skill").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / ".hub" / "some-skill" / "SKILL.md").write_text("") + + fake_cmds = { + "/some-skill": { + "name": "some-skill", + "description": "Hub skill", + "skill_md_path": f"{fake_skills_dir}/.hub/some-skill/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert categories == {} + assert uncategorized == [] + + def test_deep_nested_skills_use_top_category(self, tmp_path, monkeypatch): + """Skills like mlops/training/axolotl should group under 'mlops'.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / "mlops" / "training" / "axolotl").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "mlops" / "training" / "axolotl" / "SKILL.md").write_text("") + (tmp_path / "skills" / "mlops" / "inference" / "vllm").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "mlops" / "inference" / "vllm" / "SKILL.md").write_text("") + + fake_cmds = { + "/axolotl": { + "name": "axolotl", + "description": "Fine-tuning with Axolotl", + "skill_md_path": f"{fake_skills_dir}/mlops/training/axolotl/SKILL.md", + }, + "/vllm": { + "name": "vllm", + "description": "vLLM inference", + "skill_md_path": f"{fake_skills_dir}/mlops/inference/vllm/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + # Both should be under 'mlops' regardless of sub-category + assert "mlops" in categories + names = {n for n, _d, _k in categories["mlops"]} + assert "axolotl" in names + assert "vllm" in names + assert len(uncategorized) == 0 From 3b50821555970c79d3c89ea5c33c7da26a3e774e Mon Sep 17 00:00:00 2001 From: Julien Talbot Date: Tue, 14 Apr 2026 16:43:23 -0700 Subject: [PATCH 112/849] feat(xai): add xAI/Grok to provider prefix stripping Add 'xai', 'x-ai', 'x.ai', 'grok' to _PROVIDER_PREFIXES so that colon-prefixed model names (e.g. xai:grok-4.20) are stripped correctly for context length lookups. Cherry-picked from PR #9184 by @Julientalbot. --- agent/model_metadata.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 3b5006648..46480da23 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -36,6 +36,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", "arcee-ai", "arceeai", + "xai", "x-ai", "x.ai", "grok", "qwen-portal", }) From b24e5ee4b0414bc775b2f64883de71c7105e74f7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:55:34 -0700 Subject: [PATCH 113/849] feat(google-workspace): add --from flag for custom sender display name (#9931) Adds --from flag to gmail send and gmail reply commands, allowing agents to customize the From header display name when sharing the same email account. Usage: --from '"Agent Name" ' Also syncs repo google_api.py with the deployed standalone implementation (replaces outdated gws_bridge thin wrapper), adds dedicated docs page under Features > Skills, and updates sidebar navigation. Requested by community user @Maxime44. --- skills/productivity/google-workspace/SKILL.md | 241 +++--- .../google-workspace/scripts/google_api.py | 750 +++++++++++++++--- .../user-guide/skills/google-workspace.md | 191 +++++ website/sidebars.ts | 3 +- 4 files changed, 961 insertions(+), 224 deletions(-) create mode 100644 website/docs/user-guide/skills/google-workspace.md diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index e4553e425..fb9f00be2 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,35 +1,19 @@ --- name: google-workspace -description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via gws CLI (googleworkspace/cli). Uses OAuth2 with automatic token refresh via bridge script. Requires gws binary. -version: 2.0.0 +description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. +version: 1.0.0 author: Nous Research license: MIT -required_credential_files: - - path: google_token.json - description: Google OAuth2 token (created by setup script) - - path: google_client_secret.json - description: Google OAuth2 client credentials (downloaded from Google Cloud Console) metadata: hermes: - tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws] + tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth] homepage: https://github.com/NousResearch/hermes-agent related_skills: [himalaya] --- # Google Workspace -Gmail, Calendar, Drive, Contacts, Sheets, and Docs — powered by `gws` (Google's official Rust CLI). The skill provides a backward-compatible Python wrapper that handles OAuth token refresh and delegates to `gws`. - -## Architecture - -``` -google_api.py → gws_bridge.py → gws CLI -(argparse compat) (token refresh) (Google APIs) -``` - -- `setup.py` handles OAuth2 (headless-compatible, works on CLI/Telegram/Discord) -- `gws_bridge.py` refreshes the Hermes token and injects it into `gws` via `GOOGLE_WORKSPACE_CLI_TOKEN` -- `google_api.py` provides the same CLI interface as v1 but delegates to `gws` +Gmail, Calendar, Drive, Contacts, Sheets, and Docs — through Hermes-managed OAuth and a thin CLI wrapper. When `gws` is installed, the skill uses it as the execution backend for broader Google Workspace coverage; otherwise it falls back to the bundled Python client implementation. ## References @@ -38,22 +22,7 @@ google_api.py → gws_bridge.py → gws CLI ## Scripts - `scripts/setup.py` — OAuth2 setup (run once to authorize) -- `scripts/gws_bridge.py` — Token refresh bridge to gws CLI -- `scripts/google_api.py` — Backward-compatible API wrapper (delegates to gws) - -## Prerequisites - -Install `gws`: - -```bash -cargo install google-workspace-cli -# or via npm (recommended, downloads prebuilt binary): -npm install -g @googleworkspace/cli -# or via Homebrew: -brew install googleworkspace-cli -``` - -Verify: `gws --version` +- `scripts/google_api.py` — compatibility wrapper CLI. It prefers `gws` for operations when available, while preserving Hermes' existing JSON output contract. ## First-Time Setup @@ -63,13 +32,7 @@ on CLI, Telegram, Discord, or any platform. Define a shorthand first: ```bash -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" -PYTHON_BIN="${HERMES_PYTHON:-python3}" -if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then - PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" -fi -GSETUP="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/setup.py" +GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" ``` ### Step 0: Check if already set up @@ -82,88 +45,166 @@ If it prints `AUTHENTICATED`, skip to Usage — setup is already done. ### Step 1: Triage — ask the user what they need +Before starting OAuth setup, ask the user TWO questions: + **Question 1: "What Google services do you need? Just email, or also Calendar/Drive/Sheets/Docs?"** -- **Email only** → Use the `himalaya` skill instead — simpler setup. -- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below. +- **Email only** → They don't need this skill at all. Use the `himalaya` skill + instead — it works with a Gmail App Password (Settings → Security → App + Passwords) and takes 2 minutes to set up. No Google Cloud project needed. + Load the himalaya skill and follow its setup instructions. -**Partial scopes**: Users can authorize only a subset of services. The setup -script accepts partial scopes and warns about missing ones. +- **Email + Calendar** → Continue with this skill, but use + `--services email,calendar` during auth so the consent screen only asks for + the scopes they actually need. -**Question 2: "Does your Google account use Advanced Protection?"** +- **Calendar/Drive/Sheets/Docs only** → Continue with this skill and use a + narrower `--services` set like `calendar,drive,sheets,docs`. -- **No / Not sure** → Normal setup. -- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first. +- **Full Workspace access** → Continue with this skill and use the default + `all` service set. + +**Question 2: "Does your Google account use Advanced Protection (hardware +security keys required to sign in)? If you're not sure, you probably don't +— it's something you would have explicitly enrolled in."** + +- **No / Not sure** → Normal setup. Continue below. +- **Yes** → Their Workspace admin must add the OAuth client ID to the org's + allowed apps list before Step 4 will work. Let them know upfront. ### Step 2: Create OAuth credentials (one-time, ~5 minutes) Tell the user: -> 1. Go to https://console.cloud.google.com/apis/credentials -> 2. Create a project (or use an existing one) -> 3. Enable the APIs you need (Gmail, Calendar, Drive, Sheets, Docs, People) -> 4. Credentials → Create Credentials → OAuth 2.0 Client ID → Desktop app -> 5. Download JSON and tell me the file path +> You need a Google Cloud OAuth client. This is a one-time setup: +> +> 1. Create or select a project: +> https://console.cloud.google.com/projectselector2/home/dashboard +> 2. Enable the required APIs from the API Library: +> https://console.cloud.google.com/apis/library +> Enable: Gmail API, Google Calendar API, Google Drive API, +> Google Sheets API, Google Docs API, People API +> 3. Create the OAuth client here: +> https://console.cloud.google.com/apis/credentials +> Credentials → Create Credentials → OAuth 2.0 Client ID +> 4. Application type: "Desktop app" → Create +> 5. If the app is still in Testing, add the user's Google account as a test user here: +> https://console.cloud.google.com/auth/audience +> Audience → Test users → Add users +> 6. Download the JSON file and tell me the file path +> +> Important Hermes CLI note: if the file path starts with `/`, do NOT send only the bare path as its own message in the CLI, because it can be mistaken for a slash command. Send it in a sentence instead, like: +> `The JSON file path is: /home/user/Downloads/client_secret_....json` + +Once they provide the path: ```bash $GSETUP --client-secret /path/to/client_secret.json ``` +If they paste the raw client ID / client secret values instead of a file path, +write a valid Desktop OAuth JSON file for them yourself, save it somewhere +explicit (for example `~/Downloads/hermes-google-client-secret.json`), then run +`--client-secret` against that file. + ### Step 3: Get authorization URL +Use the service set chosen in Step 1. Examples: + ```bash -$GSETUP --auth-url +$GSETUP --auth-url --services email,calendar --format json +$GSETUP --auth-url --services calendar,drive,sheets,docs --format json +$GSETUP --auth-url --services all --format json ``` -Send the URL to the user. After authorizing, they paste back the redirect URL or code. +This returns JSON with an `auth_url` field and also saves the exact URL to +`~/.hermes/google_oauth_last_url.txt`. + +Agent rules for this step: +- Extract the `auth_url` field and send that exact URL to the user as a single line. +- Tell the user that the browser will likely fail on `http://localhost:1` after approval, and that this is expected. +- Tell them to copy the ENTIRE redirected URL from the browser address bar. +- If the user gets `Error 403: access_denied`, send them directly to `https://console.cloud.google.com/auth/audience` to add themselves as a test user. ### Step 4: Exchange the code +The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...` +or just the code string. Either works. The `--auth-url` step stores a temporary +pending OAuth session locally so `--auth-code` can complete the PKCE exchange +later, even on headless systems: + ```bash -$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" +$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" --format json ``` +If `--auth-code` fails because the code expired, was already used, or came from +an older browser tab, it now returns a fresh `fresh_auth_url`. In that case, +immediately send the new URL to the user and have them retry with the newest +browser redirect only. + ### Step 5: Verify ```bash $GSETUP --check ``` -Should print `AUTHENTICATED`. Token refreshes automatically from now on. +Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on. + +### Notes + +- Token is stored at `~/.hermes/google_token.json` and auto-refreshes. +- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. +- If `gws` is installed, `google_api.py` points it at the same `~/.hermes/google_token.json` credentials file. Users do not need to run a separate `gws auth login` flow. +- To revoke: `$GSETUP --revoke` ## Usage -All commands go through the API script: +All commands go through the API script. Set `GAPI` as a shorthand: ```bash -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" -PYTHON_BIN="${HERMES_PYTHON:-python3}" -if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then - PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" -fi -GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py" +GAPI="python ~/.hermes/skills/productivity/google-workspace/scripts/google_api.py" ``` ### Gmail ```bash +# Search (returns JSON array with id, from, subject, date, snippet) $GAPI gmail search "is:unread" --max 10 +$GAPI gmail search "from:boss@company.com newer_than:1d" +$GAPI gmail search "has:attachment filename:pdf newer_than:7d" + +# Read full message (returns JSON with body text) $GAPI gmail get MESSAGE_ID + +# Send $GAPI gmail send --to user@example.com --subject "Hello" --body "Message text" -$GAPI gmail send --to user@example.com --subject "Report" --body "

Q4

" --html +$GAPI gmail send --to user@example.com --subject "Report" --body "

Q4

Details...

" --html +$GAPI gmail send --to user@example.com --subject "Hello" --from '"Research Agent" ' --body "Message text" + +# Reply (automatically threads and sets In-Reply-To) $GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." +$GAPI gmail reply MESSAGE_ID --from '"Support Bot" ' --body "Thanks" + +# Labels $GAPI gmail labels $GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID +$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD ``` ### Calendar ```bash +# List events (defaults to next 7 days) $GAPI calendar list -$GAPI calendar create --summary "Standup" --start 2026-03-01T10:00:00+01:00 --end 2026-03-01T10:30:00+01:00 -$GAPI calendar create --summary "Review" --start ... --end ... --attendees "alice@co.com,bob@co.com" +$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z + +# Create event (ISO 8601 with timezone required) +$GAPI calendar create --summary "Team Standup" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00 +$GAPI calendar create --summary "Lunch" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location "Cafe" +$GAPI calendar create --summary "Review" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees "alice@co.com,bob@co.com" + +# Delete event $GAPI calendar delete EVENT_ID ``` @@ -183,8 +224,13 @@ $GAPI contacts list --max 20 ### Sheets ```bash +# Read $GAPI sheets get SHEET_ID "Sheet1!A1:D10" + +# Write $GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]' + +# Append rows $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' ``` @@ -194,52 +240,37 @@ $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' $GAPI docs get DOC_ID ``` -### Direct gws access (advanced) - -For operations not covered by the wrapper, use `gws_bridge.py` directly: - -```bash -GBRIDGE="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/gws_bridge.py" -$GBRIDGE calendar +agenda --today --format table -$GBRIDGE gmail +triage --labels --format json -$GBRIDGE drive +upload ./report.pdf -$GBRIDGE sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:D10" -``` - ## Output Format -All commands return JSON via `gws --format json`. Key output shapes: +All commands return JSON. Parse with `jq` or read directly. Key fields: -- **Gmail search/triage**: Array of message summaries (sender, subject, date, snippet) -- **Gmail get/read**: Message object with headers and body text -- **Gmail send/reply**: Confirmation with message ID -- **Calendar list/agenda**: Array of event objects (summary, start, end, location) -- **Calendar create**: Confirmation with event ID and htmlLink -- **Drive search**: Array of file objects (id, name, mimeType, webViewLink) -- **Sheets get/read**: 2D array of cell values -- **Docs get**: Full document JSON (use `body.content` for text extraction) -- **Contacts list**: Array of person objects with names, emails, phones - -Parse output with `jq` or read JSON directly. +- **Gmail search**: `[{id, threadId, from, to, subject, date, snippet, labels}]` +- **Gmail get**: `{id, threadId, from, to, subject, date, labels, body}` +- **Gmail send/reply**: `{status: "sent", id, threadId}` +- **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]` +- **Calendar create**: `{status: "created", id, summary, htmlLink}` +- **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]` +- **Contacts list**: `[{name, emails: [...], phones: [...]}]` +- **Sheets get**: `[[cell, cell, ...], ...]` ## Rules -1. **Never send email or create/delete events without confirming with the user first.** -2. **Check auth before first use** — run `setup.py --check`. -3. **Use the Gmail search syntax reference** for complex queries. -4. **Calendar times must include timezone** — ISO 8601 with offset or UTC. -5. **Respect rate limits** — avoid rapid-fire sequential API calls. +1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval. +2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup. +3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view("google-workspace", file_path="references/gmail-search-syntax.md")`. +4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`). +5. **Respect rate limits** — avoid rapid-fire sequential API calls. Batch reads when possible. ## Troubleshooting | Problem | Fix | |---------|-----| -| `NOT_AUTHENTICATED` | Run setup Steps 2-5 | -| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 | -| `gws: command not found` | Install: `npm install -g @googleworkspace/cli` | -| `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 | -| `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console | -| Advanced Protection blocks auth | Admin must allowlist the OAuth client ID | +| `NOT_AUTHENTICATED` | Run setup Steps 2-5 above | +| `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 | +| `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 | +| `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console | +| `ModuleNotFoundError` | Run `$GSETUP --install-deps` | +| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID | ## Revoking Access diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index ae8732f4b..5289539aa 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """Google Workspace API CLI for Hermes Agent. -Thin wrapper that delegates to gws (googleworkspace/cli) via gws_bridge.py. -Maintains the same CLI interface for backward compatibility with Hermes skills. +Uses the Google Workspace CLI (`gws`) when available, but preserves the +existing Hermes-facing JSON contract and falls back to the Python client +libraries if `gws` is not installed. Usage: python google_api.py gmail search "is:unread" [--max 10] python google_api.py gmail get MESSAGE_ID python google_api.py gmail send --to user@example.com --subject "Hi" --body "Hello" python google_api.py gmail reply MESSAGE_ID --body "Thanks" - python google_api.py calendar list [--start DATE] [--end DATE] [--calendar primary] + python google_api.py calendar list [--from DATE] [--to DATE] [--calendar primary] python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME - python google_api.py calendar delete EVENT_ID python google_api.py drive search "budget report" [--max 10] python google_api.py contacts list [--max 20] python google_api.py sheets get SHEET_ID RANGE @@ -21,47 +21,396 @@ Usage: """ import argparse +import base64 import json import os +import shutil import subprocess import sys +from datetime import datetime, timedelta, timezone +from email.mime.text import MIMEText from pathlib import Path -BRIDGE = Path(__file__).parent / "gws_bridge.py" -PYTHON = sys.executable +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +TOKEN_PATH = HERMES_HOME / "google_token.json" +CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" + +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/documents.readonly", +] -def gws(*args: str) -> None: - """Call gws via the bridge and exit with its return code.""" +def _ensure_authenticated(): + if not TOKEN_PATH.exists(): + print("Not authenticated. Run the setup script first:", file=sys.stderr) + print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr) + sys.exit(1) + + +def _stored_token_scopes() -> list[str]: + try: + data = json.loads(TOKEN_PATH.read_text()) + except Exception: + return list(SCOPES) + scopes = data.get("scopes") + if isinstance(scopes, list) and scopes: + return scopes + return list(SCOPES) + + +def _gws_binary() -> str | None: + override = os.getenv("HERMES_GWS_BIN") + if override: + return override + return shutil.which("gws") + + +def _gws_env() -> dict[str, str]: + env = os.environ.copy() + env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH) + return env + + +def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None = None): + binary = _gws_binary() + if not binary: + raise RuntimeError("gws not installed") + + _ensure_authenticated() + + cmd = [binary, *parts] + if params is not None: + cmd.extend(["--params", json.dumps(params)]) + if body is not None: + cmd.extend(["--json", json.dumps(body)]) + result = subprocess.run( - [PYTHON, str(BRIDGE)] + list(args), - env={**os.environ, "HERMES_HOME": os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))}, + cmd, + capture_output=True, + text=True, + env=_gws_env(), ) - sys.exit(result.returncode) + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() or "Unknown gws error" + print(err, file=sys.stderr) + sys.exit(result.returncode or 1) + + stdout = result.stdout.strip() + if not stdout: + return {} + + try: + return json.loads(stdout) + except json.JSONDecodeError: + print("ERROR: Unexpected non-JSON output from gws:", file=sys.stderr) + print(stdout, file=sys.stderr) + sys.exit(1) -# -- Gmail -- +def _headers_dict(msg: dict) -> dict[str, str]: + return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + + +def _extract_message_body(msg: dict) -> str: + body = "" + payload = msg.get("payload", {}) + if payload.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace") + elif payload.get("parts"): + for part in payload["parts"]: + if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + if not body: + for part in payload["parts"]: + if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace") + break + return body + + +def _extract_doc_text(doc: dict) -> str: + text_parts = [] + for element in doc.get("body", {}).get("content", []): + paragraph = element.get("paragraph", {}) + for pe in paragraph.get("elements", []): + text_run = pe.get("textRun", {}) + if text_run.get("content"): + text_parts.append(text_run["content"]) + return "".join(text_parts) + + +def _datetime_with_timezone(value: str) -> str: + if not value: + return value + if "T" not in value: + return value + if value.endswith("Z"): + return value + tail = value[10:] + if "+" in tail or "-" in tail: + return value + return value + "Z" + + +def get_credentials(): + """Load and refresh credentials from token file.""" + _ensure_authenticated() + + from google.oauth2.credentials import Credentials + from google.auth.transport.requests import Request + + creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), _stored_token_scopes()) + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + TOKEN_PATH.write_text(creds.to_json()) + if not creds.valid: + print("Token is invalid. Re-run setup.", file=sys.stderr) + sys.exit(1) + return creds + + +def build_service(api, version): + from googleapiclient.discovery import build + + return build(api, version, credentials=get_credentials()) + + +# ========================================================================= +# Gmail +# ========================================================================= + def gmail_search(args): - cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"] - gws(*cmd) + if _gws_binary(): + results = _run_gws( + ["gmail", "users", "messages", "list"], + params={"userId": "me", "q": args.query, "maxResults": args.max}, + ) + messages = results.get("messages", []) + output = [] + for msg_meta in messages: + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": msg_meta["id"], + "format": "metadata", + "metadataHeaders": ["From", "To", "Subject", "Date"], + }, + ) + headers = _headers_dict(msg) + output.append( + { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + "labels": msg.get("labelIds", []), + } + ) + print(json.dumps(output, indent=2, ensure_ascii=False)) + return + + service = build_service("gmail", "v1") + results = service.users().messages().list( + userId="me", q=args.query, maxResults=args.max + ).execute() + messages = results.get("messages", []) + if not messages: + print("No messages found.") + return + + output = [] + for msg_meta in messages: + msg = service.users().messages().get( + userId="me", id=msg_meta["id"], format="metadata", + metadataHeaders=["From", "To", "Subject", "Date"], + ).execute() + headers = _headers_dict(msg) + output.append({ + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + "labels": msg.get("labelIds", []), + }) + print(json.dumps(output, indent=2, ensure_ascii=False)) + + def gmail_get(args): - gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json") + if _gws_binary(): + msg = _run_gws( + ["gmail", "users", "messages", "get"], + params={"userId": "me", "id": args.message_id, "format": "full"}, + ) + headers = _headers_dict(msg) + result = { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "labels": msg.get("labelIds", []), + "body": _extract_message_body(msg), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + service = build_service("gmail", "v1") + msg = service.users().messages().get( + userId="me", id=args.message_id, format="full" + ).execute() + + headers = _headers_dict(msg) + result = { + "id": msg["id"], + "threadId": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "labels": msg.get("labelIds", []), + "body": _extract_message_body(msg), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + + def gmail_send(args): - cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"] + if _gws_binary(): + message = MIMEText(args.body, "html" if args.html else "plain") + message["to"] = args.to + message["subject"] = args.subject + if args.cc: + message["cc"] = args.cc + if args.from_header: + message["from"] = args.from_header + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw} + if args.thread_id: + body["threadId"] = args.thread_id + + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body=body, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + + service = build_service("gmail", "v1") + message = MIMEText(args.body, "html" if args.html else "plain") + message["to"] = args.to + message["subject"] = args.subject if args.cc: - cmd += ["--cc", args.cc] - if args.html: - cmd.append("--html") - gws(*cmd) + message["cc"] = args.cc + if args.from_header: + message["from"] = args.from_header + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw} + + if args.thread_id: + body["threadId"] = args.thread_id + + result = service.users().messages().send(userId="me", body=body).execute() + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + + def gmail_reply(args): - gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json") + if _gws_binary(): + original = _run_gws( + ["gmail", "users", "messages", "get"], + params={ + "userId": "me", + "id": args.message_id, + "format": "metadata", + "metadataHeaders": ["From", "Subject", "Message-ID"], + }, + ) + headers = _headers_dict(original) + + subject = headers.get("Subject", "") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + + message = MIMEText(args.body) + message["to"] = headers.get("From", "") + message["subject"] = subject + if args.from_header: + message["from"] = args.from_header + if headers.get("Message-ID"): + message["In-Reply-To"] = headers["Message-ID"] + message["References"] = headers["Message-ID"] + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + result = _run_gws( + ["gmail", "users", "messages", "send"], + params={"userId": "me"}, + body={"raw": raw, "threadId": original["threadId"]}, + ) + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + return + + service = build_service("gmail", "v1") + original = service.users().messages().get( + userId="me", id=args.message_id, format="metadata", + metadataHeaders=["From", "Subject", "Message-ID"], + ).execute() + headers = _headers_dict(original) + + subject = headers.get("Subject", "") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + + message = MIMEText(args.body) + message["to"] = headers.get("From", "") + message["subject"] = subject + if args.from_header: + message["from"] = args.from_header + if headers.get("Message-ID"): + message["In-Reply-To"] = headers["Message-ID"] + message["References"] = headers["Message-ID"] + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + body = {"raw": raw, "threadId": original["threadId"]} + + result = service.users().messages().send(userId="me", body=body).execute() + print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2)) + + def gmail_labels(args): - gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json") + if _gws_binary(): + results = _run_gws(["gmail", "users", "labels", "list"], params={"userId": "me"}) + labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] + print(json.dumps(labels, indent=2)) + return + + service = build_service("gmail", "v1") + results = service.users().labels().list(userId="me").execute() + labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])] + print(json.dumps(labels, indent=2)) + + def gmail_modify(args): body = {} @@ -69,145 +418,310 @@ def gmail_modify(args): body["addLabelIds"] = args.add_labels.split(",") if args.remove_labels: body["removeLabelIds"] = args.remove_labels.split(",") - gws( - "gmail", "users", "messages", "modify", - "--params", json.dumps({"userId": "me", "id": args.message_id}), - "--json", json.dumps(body), - "--format", "json", - ) + + if _gws_binary(): + result = _run_gws( + ["gmail", "users", "messages", "modify"], + params={"userId": "me", "id": args.message_id}, + body=body, + ) + print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) + return + + service = build_service("gmail", "v1") + result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute() + print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2)) -# -- Calendar -- +# ========================================================================= +# Calendar +# ========================================================================= + def calendar_list(args): - if args.start or args.end: - # Specific date range — use raw Calendar API for precise timeMin/timeMax - from datetime import datetime, timedelta, timezone as tz - now = datetime.now(tz.utc) - time_min = args.start or now.isoformat() - time_max = args.end or (now + timedelta(days=7)).isoformat() - gws( - "calendar", "events", "list", - "--params", json.dumps({ + now = datetime.now(timezone.utc) + time_min = _datetime_with_timezone(args.start or now.isoformat()) + time_max = _datetime_with_timezone(args.end or (now + timedelta(days=7)).isoformat()) + + if _gws_binary(): + results = _run_gws( + ["calendar", "events", "list"], + params={ "calendarId": args.calendar, "timeMin": time_min, "timeMax": time_max, "maxResults": args.max, "singleEvents": True, "orderBy": "startTime", - }), - "--format", "json", + }, ) - else: - # No date range — use +agenda helper (defaults to 7 days) - cmd = ["calendar", "+agenda", "--days", "7", "--format", "json"] - if args.calendar != "primary": - cmd += ["--calendar", args.calendar] - gws(*cmd) + events = [] + for e in results.get("items", []): + events.append({ + "id": e["id"], + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")), + "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")), + "location": e.get("location", ""), + "description": e.get("description", ""), + "status": e.get("status", ""), + "htmlLink": e.get("htmlLink", ""), + }) + print(json.dumps(events, indent=2, ensure_ascii=False)) + return + + service = build_service("calendar", "v3") + results = service.events().list( + calendarId=args.calendar, timeMin=time_min, timeMax=time_max, + maxResults=args.max, singleEvents=True, orderBy="startTime", + ).execute() + + events = [] + for e in results.get("items", []): + events.append({ + "id": e["id"], + "summary": e.get("summary", "(no title)"), + "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")), + "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")), + "location": e.get("location", ""), + "description": e.get("description", ""), + "status": e.get("status", ""), + "htmlLink": e.get("htmlLink", ""), + }) + print(json.dumps(events, indent=2, ensure_ascii=False)) + + def calendar_create(args): - cmd = [ - "calendar", "+insert", - "--summary", args.summary, - "--start", args.start, - "--end", args.end, - "--format", "json", - ] + event = { + "summary": args.summary, + "start": {"dateTime": args.start}, + "end": {"dateTime": args.end}, + } if args.location: - cmd += ["--location", args.location] + event["location"] = args.location if args.description: - cmd += ["--description", args.description] + event["description"] = args.description if args.attendees: - for email in args.attendees.split(","): - cmd += ["--attendee", email.strip()] - if args.calendar != "primary": - cmd += ["--calendar", args.calendar] - gws(*cmd) + event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",") if e.strip()] + + if _gws_binary(): + result = _run_gws( + ["calendar", "events", "insert"], + params={"calendarId": args.calendar}, + body=event, + ) + print(json.dumps({ + "status": "created", + "id": result["id"], + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + }, indent=2)) + return + + service = build_service("calendar", "v3") + result = service.events().insert(calendarId=args.calendar, body=event).execute() + print(json.dumps({ + "status": "created", + "id": result["id"], + "summary": result.get("summary", ""), + "htmlLink": result.get("htmlLink", ""), + }, indent=2)) + + def calendar_delete(args): - gws( - "calendar", "events", "delete", - "--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}), - "--format", "json", - ) + if _gws_binary(): + _run_gws(["calendar", "events", "delete"], params={"calendarId": args.calendar, "eventId": args.event_id}) + print(json.dumps({"status": "deleted", "eventId": args.event_id})) + return + + service = build_service("calendar", "v3") + service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute() + print(json.dumps({"status": "deleted", "eventId": args.event_id})) -# -- Drive -- +# ========================================================================= +# Drive +# ========================================================================= + def drive_search(args): query = args.query if args.raw_query else f"fullText contains '{args.query}'" - gws( - "drive", "files", "list", - "--params", json.dumps({ - "q": query, - "pageSize": args.max, - "fields": "files(id,name,mimeType,modifiedTime,webViewLink)", - }), - "--format", "json", - ) + if _gws_binary(): + results = _run_gws( + ["drive", "files", "list"], + params={ + "q": query, + "pageSize": args.max, + "fields": "files(id, name, mimeType, modifiedTime, webViewLink)", + }, + ) + print(json.dumps(results.get("files", []), indent=2, ensure_ascii=False)) + return + + service = build_service("drive", "v3") + results = service.files().list( + q=query, pageSize=args.max, fields="files(id, name, mimeType, modifiedTime, webViewLink)", + ).execute() + files = results.get("files", []) + print(json.dumps(files, indent=2, ensure_ascii=False)) -# -- Contacts -- +# ========================================================================= +# Contacts +# ========================================================================= + def contacts_list(args): - gws( - "people", "people", "connections", "list", - "--params", json.dumps({ - "resourceName": "people/me", - "pageSize": args.max, - "personFields": "names,emailAddresses,phoneNumbers", - }), - "--format", "json", - ) + if _gws_binary(): + results = _run_gws( + ["people", "people", "connections", "list"], + params={ + "resourceName": "people/me", + "pageSize": args.max, + "personFields": "names,emailAddresses,phoneNumbers", + }, + ) + contacts = [] + for person in results.get("connections", []): + names = person.get("names", [{}]) + emails = person.get("emailAddresses", []) + phones = person.get("phoneNumbers", []) + contacts.append({ + "name": names[0].get("displayName", "") if names else "", + "emails": [e.get("value", "") for e in emails], + "phones": [p.get("value", "") for p in phones], + }) + print(json.dumps(contacts, indent=2, ensure_ascii=False)) + return + + service = build_service("people", "v1") + results = service.people().connections().list( + resourceName="people/me", + pageSize=args.max, + personFields="names,emailAddresses,phoneNumbers", + ).execute() + contacts = [] + for person in results.get("connections", []): + names = person.get("names", [{}]) + emails = person.get("emailAddresses", []) + phones = person.get("phoneNumbers", []) + contacts.append({ + "name": names[0].get("displayName", "") if names else "", + "emails": [e.get("value", "") for e in emails], + "phones": [p.get("value", "") for p in phones], + }) + print(json.dumps(contacts, indent=2, ensure_ascii=False)) -# -- Sheets -- +# ========================================================================= +# Sheets +# ========================================================================= + def sheets_get(args): - gws( - "sheets", "+read", - "--spreadsheet", args.sheet_id, - "--range", args.range, - "--format", "json", - ) + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "get"], + params={"spreadsheetId": args.sheet_id, "range": args.range}, + ) + print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + return + + service = build_service("sheets", "v4") + result = service.spreadsheets().values().get( + spreadsheetId=args.sheet_id, range=args.range, + ).execute() + print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False)) + + def sheets_update(args): values = json.loads(args.values) - gws( - "sheets", "spreadsheets", "values", "update", - "--params", json.dumps({ - "spreadsheetId": args.sheet_id, - "range": args.range, - "valueInputOption": "USER_ENTERED", - }), - "--json", json.dumps({"values": values}), - "--format", "json", - ) + body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "update"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + return + + service = build_service("sheets", "v4") + result = service.spreadsheets().values().update( + spreadsheetId=args.sheet_id, range=args.range, + valueInputOption="USER_ENTERED", body=body, + ).execute() + print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2)) + + def sheets_append(args): values = json.loads(args.values) - gws( - "sheets", "+append", - "--spreadsheet", args.sheet_id, - "--json-values", json.dumps(values), - "--format", "json", - ) + body = {"values": values} + + if _gws_binary(): + result = _run_gws( + ["sheets", "spreadsheets", "values", "append"], + params={ + "spreadsheetId": args.sheet_id, + "range": args.range, + "valueInputOption": "USER_ENTERED", + "insertDataOption": "INSERT_ROWS", + }, + body=body, + ) + print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) + return + + service = build_service("sheets", "v4") + result = service.spreadsheets().values().append( + spreadsheetId=args.sheet_id, range=args.range, + valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body, + ).execute() + print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2)) -# -- Docs -- +# ========================================================================= +# Docs +# ========================================================================= + def docs_get(args): - gws( - "docs", "documents", "get", - "--params", json.dumps({"documentId": args.doc_id}), - "--format", "json", - ) + if _gws_binary(): + doc = _run_gws(["docs", "documents", "get"], params={"documentId": args.doc_id}) + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": _extract_doc_text(doc), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return + + service = build_service("docs", "v1") + doc = service.documents().get(documentId=args.doc_id).execute() + result = { + "title": doc.get("title", ""), + "documentId": doc.get("documentId", ""), + "body": _extract_doc_text(doc), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) -# -- CLI parser (backward-compatible interface) -- +# ========================================================================= +# CLI parser +# ========================================================================= + def main(): - parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)") + parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent") sub = parser.add_subparsers(dest="service", required=True) # --- Gmail --- @@ -228,13 +742,15 @@ def main(): p.add_argument("--subject", required=True) p.add_argument("--body", required=True) p.add_argument("--cc", default="") + p.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" ')") p.add_argument("--html", action="store_true", help="Send body as HTML") - p.add_argument("--thread-id", default="", help="Thread ID (unused with gws, kept for compat)") + p.add_argument("--thread-id", default="", help="Thread ID for threading") p.set_defaults(func=gmail_send) p = gmail_sub.add_parser("reply") p.add_argument("message_id", help="Message ID to reply to") p.add_argument("--body", required=True) + p.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" ')") p.set_defaults(func=gmail_reply) p = gmail_sub.add_parser("labels") diff --git a/website/docs/user-guide/skills/google-workspace.md b/website/docs/user-guide/skills/google-workspace.md new file mode 100644 index 000000000..920e6e260 --- /dev/null +++ b/website/docs/user-guide/skills/google-workspace.md @@ -0,0 +1,191 @@ +--- +sidebar_position: 2 +sidebar_label: "Google Workspace" +title: "Google Workspace — Gmail, Calendar, Drive, Sheets & Docs" +description: "Send email, manage calendar events, search Drive, read/write Sheets, and access Docs — all through OAuth2-authenticated Google APIs" +--- + +# Google Workspace Skill + +Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/nicholasgasior/gws) when available for broader coverage, and falls back to Google's Python client libraries otherwise. + +**Skill path:** `skills/productivity/google-workspace/` + +## Setup + +The setup is fully agent-driven — ask Hermes to set up Google Workspace and it walks you through each step. The flow: + +1. **Create a Google Cloud project** and enable the required APIs (Gmail, Calendar, Drive, Sheets, Docs, People) +2. **Create OAuth 2.0 credentials** (Desktop app type) and download the client secret JSON +3. **Authorize** — Hermes generates an auth URL, you approve in the browser, paste back the redirect URL +4. **Done** — token auto-refreshes from that point on + +:::tip Email-only users +If you only need email (no Calendar/Drive/Sheets), use the **himalaya** skill instead — it works with a Gmail App Password and takes 2 minutes. No Google Cloud project needed. +::: + +## Gmail + +### Searching + +```bash +$GAPI gmail search "is:unread" --max 10 +$GAPI gmail search "from:boss@company.com newer_than:1d" +$GAPI gmail search "has:attachment filename:pdf newer_than:7d" +``` + +Returns JSON with `id`, `from`, `subject`, `date`, `snippet`, and `labels` for each message. + +### Reading + +```bash +$GAPI gmail get MESSAGE_ID +``` + +Returns the full message body as text (prefers plain text, falls back to HTML). + +### Sending + +```bash +# Basic send +$GAPI gmail send --to user@example.com --subject "Hello" --body "Message text" + +# HTML email +$GAPI gmail send --to user@example.com --subject "Report" \ + --body "

Q4 Results

Details here

" --html + +# Custom From header (display name + email) +$GAPI gmail send --to user@example.com --subject "Hello" \ + --from '"Research Agent" ' --body "Message text" + +# With CC +$GAPI gmail send --to user@example.com --cc "team@example.com" \ + --subject "Update" --body "FYI" +``` + +### Custom From Header + +The `--from` flag lets you customize the sender display name on outgoing emails. This is useful when multiple agents share the same Gmail account but you want recipients to see different names: + +```bash +# Agent 1 +$GAPI gmail send --to client@co.com --subject "Research Summary" \ + --from '"Research Agent" ' --body "..." + +# Agent 2 +$GAPI gmail send --to client@co.com --subject "Code Review" \ + --from '"Code Assistant" ' --body "..." +``` + +**How it works:** The `--from` value is set as the RFC 5322 `From` header on the MIME message. Gmail allows customizing the display name on your own authenticated email address without any additional configuration. Recipients see the custom display name (e.g. "Research Agent") while the email address stays the same. + +**Important:** If you use a *different email address* in `--from` (not the authenticated account), Gmail requires that address to be configured as a [Send As alias](https://support.google.com/mail/answer/22370) in Gmail Settings → Accounts → Send mail as. + +The `--from` flag works on both `send` and `reply`: + +```bash +$GAPI gmail reply MESSAGE_ID \ + --from '"Support Bot" ' --body "We're on it" +``` + +### Replying + +```bash +$GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me." +``` + +Automatically threads the reply (sets `In-Reply-To` and `References` headers) and uses the original message's thread ID. + +### Labels + +```bash +# List all labels +$GAPI gmail labels + +# Add/remove labels +$GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID +$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD +``` + +## Calendar + +```bash +# List events (defaults to next 7 days) +$GAPI calendar list +$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z + +# Create event (timezone required) +$GAPI calendar create --summary "Team Standup" \ + --start 2026-03-01T10:00:00-07:00 --end 2026-03-01T10:30:00-07:00 + +# With location and attendees +$GAPI calendar create --summary "Lunch" \ + --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z \ + --location "Cafe" --attendees "alice@co.com,bob@co.com" + +# Delete event +$GAPI calendar delete EVENT_ID +``` + +:::warning +Calendar times **must** include a timezone offset (e.g. `-07:00`) or use UTC (`Z`). Bare datetimes like `2026-03-01T10:00:00` are ambiguous and will be treated as UTC. +::: + +## Drive + +```bash +$GAPI drive search "quarterly report" --max 10 +$GAPI drive search "mimeType='application/pdf'" --raw-query --max 5 +``` + +## Sheets + +```bash +# Read a range +$GAPI sheets get SHEET_ID "Sheet1!A1:D10" + +# Write to a range +$GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]' + +# Append rows +$GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]' +``` + +## Docs + +```bash +$GAPI docs get DOC_ID +``` + +Returns the document title and full text content. + +## Contacts + +```bash +$GAPI contacts list --max 20 +``` + +## Output Format + +All commands return JSON. Key fields per service: + +| Command | Fields | +|---------|--------| +| `gmail search` | `id`, `threadId`, `from`, `to`, `subject`, `date`, `snippet`, `labels` | +| `gmail get` | `id`, `threadId`, `from`, `to`, `subject`, `date`, `labels`, `body` | +| `gmail send/reply` | `status`, `id`, `threadId` | +| `calendar list` | `id`, `summary`, `start`, `end`, `location`, `description`, `htmlLink` | +| `calendar create` | `status`, `id`, `summary`, `htmlLink` | +| `drive search` | `id`, `name`, `mimeType`, `modifiedTime`, `webViewLink` | +| `contacts list` | `name`, `emails`, `phones` | +| `sheets get` | 2D array of cell values | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `NOT_AUTHENTICATED` | Run setup (ask Hermes to set up Google Workspace) | +| `REFRESH_FAILED` | Token revoked — re-run authorization steps | +| `HttpError 403: Insufficient Permission` | Missing scope — revoke and re-authorize with the right services | +| `HttpError 403: Access Not Configured` | API not enabled in Google Cloud Console | +| `ModuleNotFoundError` | Run setup script with `--install-deps` | diff --git a/website/sidebars.ts b/website/sidebars.ts index 771bd07a7..a1633f229 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -92,6 +92,7 @@ const sidebars: SidebarsConfig = { label: 'Skills', items: [ 'user-guide/skills/godmode', + 'user-guide/skills/google-workspace', ], }, ], @@ -118,7 +119,6 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/wecom-callback', 'user-guide/messaging/weixin', 'user-guide/messaging/bluebubbles', - 'user-guide/messaging/qqbot', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], @@ -153,7 +153,6 @@ const sidebars: SidebarsConfig = { 'guides/use-voice-mode-with-hermes', 'guides/build-a-hermes-plugin', 'guides/automate-with-cron', - 'guides/automation-templates', 'guides/cron-troubleshooting', 'guides/work-with-skills', 'guides/delegation-patterns', From cfa24532d3a5bf3e199fcac3316a1479cdfe418b Mon Sep 17 00:00:00 2001 From: areu01or00 Date: Tue, 14 Apr 2026 01:52:22 +0530 Subject: [PATCH 114/849] fix(discord): register native /restart slash command --- gateway/platforms/discord.py | 4 ++++ tests/gateway/test_discord_slash_commands.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 51ef0a868..a80790ed5 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1696,6 +1696,10 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_update(interaction: discord.Interaction): await self._run_simple_slash(interaction, "/update", "Update initiated~") + @tree.command(name="restart", description="Gracefully restart the Hermes gateway") + async def slash_restart(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/restart", "Restart requested~") + @tree.command(name="approve", description="Approve a pending dangerous command") @discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'") async def slash_approve(interaction: discord.Interaction, scope: str = ""): diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index b7967c69a..c1c3c1df1 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -117,6 +117,23 @@ async def test_registers_native_thread_slash_command(adapter): adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440) +@pytest.mark.asyncio +async def test_registers_native_restart_slash_command(adapter): + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + assert "restart" in adapter._client.tree.commands + + interaction = SimpleNamespace() + await adapter._client.tree.commands["restart"](interaction) + + adapter._run_simple_slash.assert_awaited_once_with( + interaction, + "/restart", + "Restart requested~", + ) + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ From 3ca7417c2a6fe7fd2c0a64e8324a1fb7fd89bf55 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 16:55:25 -0700 Subject: [PATCH 115/849] chore: add areu01or00 to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 08af431f2..1d80499ca 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -96,6 +96,7 @@ AUTHOR_MAP = { "aryan@synvoid.com": "aryansingh", "johnsonblake1@gmail.com": "blakejohnson", "kennyx102@gmail.com": "bobashopcashier", + "shokatalishaikh95@gmail.com": "areu01or00", "bryan@intertwinesys.com": "bryanyoung", "christo.mitov@gmail.com": "christomitov", "hermes@nousresearch.com": "NousResearch", From 56c34ac4f73d145cef6eb3847855573a13e56588 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:47:36 -0700 Subject: [PATCH 116/849] fix(browser): add termux PATH fallbacks Refactor browser tool PATH construction to include Termux directories (/data/data/com.termux/files/usr/bin, /data/data/com.termux/files/usr/sbin) so agent-browser and npx are discoverable on Android/Termux. Extracts _browser_candidate_path_dirs() and _merge_browser_path() helpers to centralize PATH construction shared between _find_agent_browser() and _run_browser_command(), replacing duplicated inline logic. Also fixes os.pathsep usage (was hardcoded ':') for cross-platform correctness. Cherry-picked from PR #9846. --- tests/tools/test_browser_homebrew_paths.py | 105 +++++++++++++++++++-- tools/browser_tool.py | 86 +++++++++-------- 2 files changed, 145 insertions(+), 46 deletions(-) diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index b54f4abb8..772a0b46b 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -31,18 +31,25 @@ def _clear_browser_caches(): class TestSanePath: - """Verify _SANE_PATH includes Homebrew directories.""" + """Verify _SANE_PATH includes fallback directories used by browser_tool.""" + + def test_includes_termux_bin(self): + assert "/data/data/com.termux/files/usr/bin" in _SANE_PATH.split(os.pathsep) + + def test_includes_termux_sbin(self): + assert "/data/data/com.termux/files/usr/sbin" in _SANE_PATH.split(os.pathsep) def test_includes_homebrew_bin(self): - assert "/opt/homebrew/bin" in _SANE_PATH + assert "/opt/homebrew/bin" in _SANE_PATH.split(os.pathsep) def test_includes_homebrew_sbin(self): - assert "/opt/homebrew/sbin" in _SANE_PATH + assert "/opt/homebrew/sbin" in _SANE_PATH.split(os.pathsep) def test_includes_standard_dirs(self): - assert "/usr/local/bin" in _SANE_PATH - assert "/usr/bin" in _SANE_PATH - assert "/bin" in _SANE_PATH + path_parts = _SANE_PATH.split(os.pathsep) + assert "/usr/local/bin" in path_parts + assert "/usr/bin" in path_parts + assert "/bin" in path_parts class TestDiscoverHomebrewNodeDirs: @@ -143,6 +150,44 @@ class TestFindAgentBrowser: result = _find_agent_browser() assert result == "npx agent-browser" + def test_finds_npx_in_termux_fallback_path(self): + """Should find npx when only Termux fallback dirs are available.""" + def mock_which(cmd, path=None): + if cmd == "agent-browser": + return None + if cmd == "npx": + if path and "/data/data/com.termux/files/usr/bin" in path: + return "/data/data/com.termux/files/usr/bin/npx" + return None + return None + + original_path_exists = Path.exists + + def mock_path_exists(self): + if "node_modules" in str(self) and "agent-browser" in str(self): + return False + return original_path_exists(self) + + real_isdir = os.path.isdir + + def selective_isdir(path): + if path in ( + "/data/data/com.termux/files/usr/bin", + "/data/data/com.termux/files/usr/sbin", + ): + return True + return real_isdir(path) + + with patch("shutil.which", side_effect=mock_which), \ + patch("os.path.isdir", side_effect=selective_isdir), \ + patch.object(Path, "exists", mock_path_exists), \ + patch( + "tools.browser_tool._discover_homebrew_node_dirs", + return_value=[], + ): + result = _find_agent_browser() + assert result == "npx agent-browser" + def test_raises_when_not_found(self): """Should raise FileNotFoundError when nothing works.""" original_path_exists = Path.exists @@ -399,3 +444,51 @@ class TestRunBrowserCommandPathConstruction: result_path = captured_env.get("PATH", "") assert "/opt/homebrew/bin" in result_path assert "/opt/homebrew/sbin" in result_path + + def test_subprocess_path_includes_termux_fallback_dirs(self, tmp_path): + """Termux fallback dirs should survive browser PATH rebuilding.""" + captured_env = {} + + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.wait.return_value = 0 + + def capture_popen(cmd, **kwargs): + captured_env.update(kwargs.get("env", {})) + return mock_proc + + fake_session = { + "session_name": "test-session", + "session_id": "test-id", + "cdp_url": None, + } + + fake_json = json.dumps({"success": True}) + real_isdir = os.path.isdir + + def selective_isdir(path): + if path in ( + "/data/data/com.termux/files/usr/bin", + "/data/data/com.termux/files/usr/sbin", + ): + return True + if path.startswith(str(tmp_path)): + return True + return real_isdir(path) + + with patch("tools.browser_tool._find_agent_browser", return_value="/usr/local/bin/agent-browser"), \ + patch("tools.browser_tool._get_session_info", return_value=fake_session), \ + patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \ + patch("tools.browser_tool._discover_homebrew_node_dirs", return_value=[]), \ + patch("os.path.isdir", side_effect=selective_isdir), \ + patch("subprocess.Popen", side_effect=capture_popen), \ + patch("os.open", return_value=99), \ + patch("os.close"), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch.dict(os.environ, {"PATH": "/usr/bin:/bin", "HOME": "/home/test"}, clear=True): + with patch("builtins.open", mock_open(read_data=fake_json)): + _run_browser_command("test-task", "navigate", ["https://example.com"]) + + result_path = captured_env.get("PATH", "") + assert "/data/data/com.termux/files/usr/bin" in result_path + assert "/data/data/com.termux/files/usr/sbin" in result_path diff --git a/tools/browser_tool.py b/tools/browser_tool.py index fd6562575..03be84e02 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -94,11 +94,21 @@ except ImportError: logger = logging.getLogger(__name__) # Standard PATH entries for environments with minimal PATH (e.g. systemd services). -# Includes macOS Homebrew paths (/opt/homebrew/* for Apple Silicon). -_SANE_PATH = ( - "/opt/homebrew/bin:/opt/homebrew/sbin:" - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +# Includes Android/Termux and macOS Homebrew locations needed for agent-browser, +# npx, node, and Android's glibc runner (grun). +_SANE_PATH_DIRS = ( + "/data/data/com.termux/files/usr/bin", + "/data/data/com.termux/files/usr/sbin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", ) +_SANE_PATH = os.pathsep.join(_SANE_PATH_DIRS) @functools.lru_cache(maxsize=1) @@ -123,6 +133,28 @@ def _discover_homebrew_node_dirs() -> tuple[str, ...]: pass return tuple(dirs) + +def _browser_candidate_path_dirs() -> list[str]: + """Return ordered browser CLI PATH candidates shared by discovery and execution.""" + hermes_home = get_hermes_home() + hermes_node_bin = str(hermes_home / "node" / "bin") + return [hermes_node_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] + + +def _merge_browser_path(existing_path: str = "") -> str: + """Prepend browser-specific PATH fallbacks without reordering existing entries.""" + path_parts = [p for p in (existing_path or "").split(os.pathsep) if p] + existing_parts = set(path_parts) + prefix_parts: list[str] = [] + + for part in _browser_candidate_path_dirs(): + if not part or part in existing_parts or part in prefix_parts: + continue + if os.path.isdir(part): + prefix_parts.append(part) + + return os.pathsep.join(prefix_parts + path_parts) + # Throttle screenshot cleanup to avoid repeated full directory scans. _last_screenshot_cleanup_by_dir: dict[str, float] = {} @@ -895,21 +927,10 @@ def _find_agent_browser() -> str: _agent_browser_resolved = True return which_result - # Build an extended search PATH including Homebrew and Hermes-managed dirs. - # This covers macOS where the process PATH may not include Homebrew paths. - extra_dirs: list[str] = [] - for d in ["/opt/homebrew/bin", "/usr/local/bin"]: - if os.path.isdir(d): - extra_dirs.append(d) - extra_dirs.extend(_discover_homebrew_node_dirs()) - - hermes_home = get_hermes_home() - hermes_node_bin = str(hermes_home / "node" / "bin") - if os.path.isdir(hermes_node_bin): - extra_dirs.append(hermes_node_bin) - - if extra_dirs: - extended_path = os.pathsep.join(extra_dirs) + # Build an extended search PATH including Hermes-managed Node, macOS + # versioned Homebrew installs, and fallback system dirs like Termux. + extended_path = _merge_browser_path("") + if extended_path: which_result = shutil.which("agent-browser", path=extended_path) if which_result: _cached_agent_browser = which_result @@ -924,10 +945,10 @@ def _find_agent_browser() -> str: _agent_browser_resolved = True return _cached_agent_browser - # Check common npx locations (also search extended dirs) + # Check common npx locations (also search the extended fallback PATH) npx_path = shutil.which("npx") - if not npx_path and extra_dirs: - npx_path = shutil.which("npx", path=os.pathsep.join(extra_dirs)) + if not npx_path and extended_path: + npx_path = shutil.which("npx", path=extended_path) if npx_path: _cached_agent_browser = "npx agent-browser" _agent_browser_resolved = True @@ -1046,24 +1067,9 @@ def _run_browser_command( browser_env = {**os.environ} - # Ensure PATH includes Hermes-managed Node first, Homebrew versioned - # node dirs (for macOS ``brew install node@24``), then standard system dirs. - hermes_home = get_hermes_home() - hermes_node_bin = str(hermes_home / "node" / "bin") - - existing_path = browser_env.get("PATH", "") - path_parts = [p for p in existing_path.split(":") if p] - candidate_dirs = ( - [hermes_node_bin] - + list(_discover_homebrew_node_dirs()) - + [p for p in _SANE_PATH.split(":") if p] - ) - - for part in reversed(candidate_dirs): - if os.path.isdir(part) and part not in path_parts: - path_parts.insert(0, part) - - browser_env["PATH"] = ":".join(path_parts) + # Ensure subprocesses inherit the same browser-specific PATH fallbacks + # used during CLI discovery. + browser_env["PATH"] = _merge_browser_path(browser_env.get("PATH", "")) browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir # Use temp files for stdout/stderr instead of pipes. From ac1f8fcccdb303d9a71494e709db65420e0b2bff Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 16:53:46 -0700 Subject: [PATCH 117/849] docs(termux): note browser tool PATH auto-discovery Update the Termux guide to mention that the browser tool now automatically discovers Termux directories, and add the missing pkg install nodejs-lts step. --- website/docs/getting-started/termux.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index 1ad71e531..eb860f85c 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -152,12 +152,15 @@ hermes setup ### Install optional Node dependencies manually -The tested Termux path skips Node/browser bootstrap on purpose. If you want to experiment later: +The tested Termux path skips Node/browser bootstrap on purpose. If you want to experiment with browser tooling later: ```bash +pkg install nodejs-lts npm install ``` +The browser tool automatically includes Termux directories (`/data/data/com.termux/files/usr/bin`) in its PATH search, so `agent-browser` and `npx` are discovered without any extra PATH configuration. + Treat browser / WhatsApp tooling on Android as experimental until documented otherwise. --- From e7475b15829faa47bf99dd1ebc8d7370e81ddf6a Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 16:55:30 -0700 Subject: [PATCH 118/849] feat: auto-continue interrupted agent work after gateway restart (#4493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway restarts mid-agent-work, the session transcript ends on a tool result the agent never processed. Previously, the user had to type 'continue' or use /retry (which replays from scratch, losing all prior work). Now, when the next user message arrives and the loaded history ends with role='tool', a system note is prepended: [System note: Your previous turn was interrupted before you could process the last tool result(s). Please finish processing those results and summarize what was accomplished, then address the user's new message below.] This is injected in _run_agent()'s run_sync closure, right before calling agent.run_conversation(). The agent sees the full history (including the pending tool results) and the system note, so it can summarize what was accomplished and then handle the user's new input. Design decisions: - No new session flags or schema changes — purely detects trailing tool messages in the loaded history - Works for any restart scenario (clean, crash, SIGTERM, drain timeout) as long as the session wasn't suspended (suspended = fresh start) - The user's actual message is preserved after the note - If the session WAS suspended (unclean shutdown), the old history is abandoned and the user starts fresh — no false auto-continue Also updates the shutdown notification message from 'Use /retry after restart to continue' to 'Send any message after restart to resume where it left off' — which is now accurate. Test plan: - 6 new auto-continue tests (trailing tool detection, no false positives for assistant/user/empty history, multi-tool, message preservation) - All 13 restart drain tests pass (updated /retry assertion) --- gateway/run.py | 17 +++++- tests/gateway/test_auto_continue.py | 95 +++++++++++++++++++++++++++++ tests/gateway/test_restart_drain.py | 2 +- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_auto_continue.py diff --git a/gateway/run.py b/gateway/run.py index 5c3e5f13c..a83fa2eed 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1405,7 +1405,7 @@ class GatewayRunner: action = "restarting" if self._restart_requested else "shutting down" hint = ( "Your current task will be interrupted. " - "Use /retry after restart to continue." + "Send any message after restart to resume where it left off." if self._restart_requested else "Your current task will be interrupted." ) @@ -8450,6 +8450,21 @@ class GatewayRunner: if _msn: message = _msn + "\n\n" + message + # Auto-continue: if the loaded history ends with a tool result, + # the previous agent turn was interrupted mid-work (gateway + # restart, crash, SIGTERM). Prepend a system note so the model + # finishes processing the pending tool results before addressing + # the user's new message. (#4493) + if agent_history and agent_history[-1].get("role") == "tool": + message = ( + "[System note: Your previous turn was interrupted before you could " + "process the last tool result(s). The conversation history contains " + "tool outputs you haven't responded to yet. Please finish processing " + "those results and summarize what was accomplished, then address the " + "user's new message below.]\n\n" + + message + ) + _approval_session_key = session_key or "" _approval_session_token = set_current_session_key(_approval_session_key) register_gateway_notify(_approval_session_key, _approval_notify_sync) diff --git a/tests/gateway/test_auto_continue.py b/tests/gateway/test_auto_continue.py new file mode 100644 index 000000000..1f44fa6ab --- /dev/null +++ b/tests/gateway/test_auto_continue.py @@ -0,0 +1,95 @@ +"""Tests for the auto-continue feature (#4493). + +When the gateway restarts mid-agent-work, the session transcript ends on a +tool result that the agent never processed. The auto-continue logic detects +this and prepends a system note to the next user message so the model +finishes the interrupted work before addressing the new input. +""" + +import pytest + + +def _simulate_auto_continue(agent_history: list, user_message: str) -> str: + """Reproduce the auto-continue injection logic from _run_agent(). + + This mirrors the exact code in gateway/run.py so we can test the + detection and message transformation without spinning up a full + gateway runner. + """ + message = user_message + if agent_history and agent_history[-1].get("role") == "tool": + message = ( + "[System note: Your previous turn was interrupted before you could " + "process the last tool result(s). The conversation history contains " + "tool outputs you haven't responded to yet. Please finish processing " + "those results and summarize what was accomplished, then address the " + "user's new message below.]\n\n" + + message + ) + return message + + +class TestAutoDetection: + """Test that trailing tool results are correctly detected.""" + + def test_trailing_tool_result_triggers_note(self): + history = [ + {"role": "user", "content": "deploy the app"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "deployed successfully"}, + ] + result = _simulate_auto_continue(history, "what happened?") + assert "[System note:" in result + assert "interrupted" in result + assert "what happened?" in result + + def test_trailing_assistant_message_no_note(self): + history = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + result = _simulate_auto_continue(history, "how are you?") + assert "[System note:" not in result + assert result == "how are you?" + + def test_empty_history_no_note(self): + result = _simulate_auto_continue([], "hello") + assert result == "hello" + + def test_trailing_user_message_no_note(self): + """Shouldn't happen in practice, but ensure no false positive.""" + history = [ + {"role": "user", "content": "hello"}, + ] + result = _simulate_auto_continue(history, "hello again") + assert result == "hello again" + + def test_multiple_tool_results_still_triggers(self): + """Multiple tool calls in a row — last one is still role=tool.""" + history = [ + {"role": "user", "content": "search and read"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "function": {"name": "search", "arguments": "{}"}}, + {"id": "call_2", "function": {"name": "read", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "found it"}, + {"role": "tool", "tool_call_id": "call_2", "content": "file content here"}, + ] + result = _simulate_auto_continue(history, "continue") + assert "[System note:" in result + + def test_original_message_preserved_after_note(self): + """The user's actual message must appear after the system note.""" + history = [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c1", "function": {"name": "t", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "c1", "content": "done"}, + ] + result = _simulate_auto_continue(history, "now do X") + # System note comes first, then user's message + note_end = result.index("]\n\n") + user_msg_start = result.index("now do X") + assert user_msg_start > note_end diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 732470c12..3607b1e39 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -193,7 +193,7 @@ async def test_shutdown_notification_says_restarting_when_restart_requested(): assert len(adapter.sent) == 1 assert "restarting" in adapter.sent[0] - assert "/retry" in adapter.sent[0] + assert "resume" in adapter.sent[0] @pytest.mark.asyncio From a9c78d0eb0efbb775cea8397d0e24407ad8a83ff Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:58:10 -0700 Subject: [PATCH 119/849] feat(setup): add recommendation badges to tool provider selection (#9929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New users don't know which tool providers to pick during setup. Add [badge] labels to each provider in the selection menu: - [★ recommended · free] for best default choices (Edge TTS, Local Browser) - [★ recommended] for top-tier paid options (Firecrawl Cloud) - [paid] for options requiring an API key - [free tier] for services with a free tier (Tavily) - [free · self-hosted] / [free · local] for self-run options - [subscription] for Nous subscription-managed options Also improves vague tag descriptions — e.g. 'AI-native search and contents' becomes 'Neural search with semantic understanding' and Tavily gets '1000 free searches/mo'. Both hermes setup and hermes tools share the same rendering path, so badges appear in both flows. Addresses user feedback about setup being confusing for newcomers. --- hermes_cli/tools_config.py | 53 ++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index abe1ff245..b518c001e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -121,6 +121,7 @@ TOOL_CATEGORIES = { "providers": [ { "name": "Nous Subscription", + "badge": "subscription", "tag": "Managed OpenAI TTS billed to your subscription", "env_vars": [], "tts_provider": "openai", @@ -130,13 +131,15 @@ TOOL_CATEGORIES = { }, { "name": "Microsoft Edge TTS", - "tag": "Free - no API key needed", + "badge": "★ recommended · free", + "tag": "Good quality, no API key needed", "env_vars": [], "tts_provider": "edge", }, { "name": "OpenAI TTS", - "tag": "Premium - high quality voices", + "badge": "paid", + "tag": "High quality voices", "env_vars": [ {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}, ], @@ -144,7 +147,8 @@ TOOL_CATEGORIES = { }, { "name": "ElevenLabs", - "tag": "Premium - most natural voices", + "badge": "paid", + "tag": "Most natural voices", "env_vars": [ {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}, ], @@ -152,7 +156,8 @@ TOOL_CATEGORIES = { }, { "name": "Mistral (Voxtral TTS)", - "tag": "Multilingual, native Opus, needs MISTRAL_API_KEY", + "badge": "paid", + "tag": "Multilingual, native Opus", "env_vars": [ {"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"}, ], @@ -168,6 +173,7 @@ TOOL_CATEGORIES = { "providers": [ { "name": "Nous Subscription", + "badge": "subscription", "tag": "Managed Firecrawl billed to your subscription", "web_backend": "firecrawl", "env_vars": [], @@ -177,7 +183,8 @@ TOOL_CATEGORIES = { }, { "name": "Firecrawl Cloud", - "tag": "Hosted service - search, extract, and crawl", + "badge": "★ recommended", + "tag": "Full-featured search, extract, and crawl", "web_backend": "firecrawl", "env_vars": [ {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, @@ -185,7 +192,8 @@ TOOL_CATEGORIES = { }, { "name": "Exa", - "tag": "AI-native search and contents", + "badge": "paid", + "tag": "Neural search with semantic understanding", "web_backend": "exa", "env_vars": [ {"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"}, @@ -193,7 +201,8 @@ TOOL_CATEGORIES = { }, { "name": "Parallel", - "tag": "AI-native search and extract", + "badge": "paid", + "tag": "AI-powered search and extract", "web_backend": "parallel", "env_vars": [ {"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"}, @@ -201,7 +210,8 @@ TOOL_CATEGORIES = { }, { "name": "Tavily", - "tag": "AI-native search, extract, and crawl", + "badge": "free tier", + "tag": "Search, extract, and crawl — 1000 free searches/mo", "web_backend": "tavily", "env_vars": [ {"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"}, @@ -209,7 +219,8 @@ TOOL_CATEGORIES = { }, { "name": "Firecrawl Self-Hosted", - "tag": "Free - run your own instance", + "badge": "free · self-hosted", + "tag": "Run your own Firecrawl instance (Docker)", "web_backend": "firecrawl", "env_vars": [ {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}, @@ -223,6 +234,7 @@ TOOL_CATEGORIES = { "providers": [ { "name": "Nous Subscription", + "badge": "subscription", "tag": "Managed FAL image generation billed to your subscription", "env_vars": [], "requires_nous_auth": True, @@ -231,6 +243,7 @@ TOOL_CATEGORIES = { }, { "name": "FAL.ai", + "badge": "paid", "tag": "FLUX 2 Pro with auto-upscaling", "env_vars": [ {"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"}, @@ -244,6 +257,7 @@ TOOL_CATEGORIES = { "providers": [ { "name": "Nous Subscription (Browser Use cloud)", + "badge": "subscription", "tag": "Managed Browser Use billed to your subscription", "env_vars": [], "browser_provider": "browser-use", @@ -254,14 +268,16 @@ TOOL_CATEGORIES = { }, { "name": "Local Browser", - "tag": "Free headless Chromium (no API key needed)", + "badge": "★ recommended · free", + "tag": "Headless Chromium, no API key needed", "env_vars": [], "browser_provider": "local", "post_setup": "agent_browser", }, { "name": "Browserbase", - "tag": "Cloud browser with stealth & proxies", + "badge": "paid", + "tag": "Cloud browser with stealth and proxies", "env_vars": [ {"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"}, {"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"}, @@ -271,6 +287,7 @@ TOOL_CATEGORIES = { }, { "name": "Browser Use", + "badge": "paid", "tag": "Cloud browser with remote execution", "env_vars": [ {"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"}, @@ -280,6 +297,7 @@ TOOL_CATEGORIES = { }, { "name": "Firecrawl", + "badge": "paid", "tag": "Cloud browser with remote execution", "env_vars": [ {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, @@ -289,7 +307,8 @@ TOOL_CATEGORIES = { }, { "name": "Camofox", - "tag": "Local anti-detection browser (Firefox/Camoufox)", + "badge": "free · local", + "tag": "Anti-detection browser (Firefox/Camoufox)", "env_vars": [ {"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", "url": "https://github.com/jo-inc/camofox-browser"}, @@ -838,7 +857,8 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): # Plain text labels only (no ANSI codes in menu items) provider_choices = [] for p in providers: - tag = f" ({p['tag']})" if p.get("tag") else "" + badge = f" [{p['badge']}]" if p.get("badge") else "" + tag = f" — {p['tag']}" if p.get("tag") else "" configured = "" env_vars = p.get("env_vars", []) if not env_vars or all(get_env_value(v["key"]) for v in env_vars): @@ -848,7 +868,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): configured = "" else: configured = " [configured]" - provider_choices.append(f"{p['name']}{tag}{configured}") + provider_choices.append(f"{p['name']}{badge}{tag}{configured}") # Add skip option provider_choices.append("Skip — keep defaults / configure later") @@ -1104,7 +1124,8 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict): provider_choices = [] for p in providers: - tag = f" ({p['tag']})" if p.get("tag") else "" + badge = f" [{p['badge']}]" if p.get("badge") else "" + tag = f" — {p['tag']}" if p.get("tag") else "" configured = "" env_vars = p.get("env_vars", []) if not env_vars or all(get_env_value(v["key"]) for v in env_vars): @@ -1114,7 +1135,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict): configured = "" else: configured = " [configured]" - provider_choices.append(f"{p['name']}{tag}{configured}") + provider_choices.append(f"{p['name']}{badge}{tag}{configured}") default_idx = _detect_active_provider_index(providers, config) From 847d7cbea582cf6d15f6c280bdf28990b1369df5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:58:23 -0700 Subject: [PATCH 120/849] fix: improve CLI text padding, word-wrap for responses and verbose tool output (#9920) * feat(skills): add fitness-nutrition skill to optional-skills Cherry-picked from PR #9177 by @haileymarshall. Adds a fitness and nutrition skill for gym-goers and health-conscious users: - Exercise search via wger API (690+ exercises, free, no auth) - Nutrition lookup via USDA FoodData Central (380K+ foods, DEMO_KEY fallback) - Offline body composition calculators (BMI, TDEE, 1RM, macros, body fat %) - Pure stdlib Python, no pip dependencies Changes from original PR: - Moved from skills/ to optional-skills/health/ (correct location) - Fixed BMR formula in FORMULAS.md (removed confusing -5+10, now just +5) - Fixed author attribution to match PR submitter - Marked USDA_API_KEY as optional (DEMO_KEY works without signup) Also adds optional env var support to the skill readiness checker: - New 'optional: true' field in required_environment_variables entries - Optional vars are preserved in metadata but don't block skill readiness - Optional vars skip the CLI capture prompt flow - Skills with only optional missing vars show as 'available' not 'setup_needed' * fix: increase CLI response text padding to 4-space tab indent Increases horizontal padding on all response display paths: - Rich Panel responses (main, background, /btw): padding (1,2) -> (1,4) - Streaming text: add 4-space indent prefix to each line - Streaming TTS: add 4-space indent prefix to sentences Gives response text proper breathing room with a tab-width indent. Rich Panel word wrapping automatically adjusts for the wider padding. Requested by AriesTheCoder. * fix: word-wrap verbose tool call args and results to terminal width Verbose mode (tool_progress: verbose) printed tool args and results as single unwrapped lines that could be thousands of characters long. Adds _wrap_verbose() helper that: - Pretty-prints JSON args with indent=2 instead of one-line dumps - Splits text on existing newlines (preserves JSON/structured output) - Wraps lines exceeding terminal width with 5-char continuation indent - Uses break_long_words=True for URLs and paths without spaces Applied to all 4 verbose print sites: - Concurrent tool call args - Concurrent tool results - Sequential tool call args - Sequential tool results --------- Co-authored-by: haileymarshall --- cli.py | 13 +++++++------ run_agent.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/cli.py b/cli.py index ebc8b7637..d679b24b5 100644 --- a/cli.py +++ b/cli.py @@ -989,6 +989,7 @@ def _prune_orphaned_branches(repo_root: str) -> None: _ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback _BOLD = "\033[1m" _RST = "\033[0m" +_STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel padding) def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str: @@ -2580,7 +2581,7 @@ class HermesCLI: _tc = getattr(self, "_stream_text_ansi", "") while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) - _cprint(f"{_tc}{line}{_RST}" if _tc else line) + _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") def _flush_stream(self) -> None: """Emit any remaining partial line from the stream buffer and close the box.""" @@ -2597,7 +2598,7 @@ class HermesCLI: if self._stream_buf: _tc = getattr(self, "_stream_text_ansi", "") - _cprint(f"{_tc}{self._stream_buf}{_RST}" if _tc else self._stream_buf) + _cprint(f"{_STREAM_PAD}{_tc}{self._stream_buf}{_RST}" if _tc else f"{_STREAM_PAD}{self._stream_buf}") self._stream_buf = "" # Close the response box @@ -5761,7 +5762,7 @@ class HermesCLI: border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, - padding=(1, 2), + padding=(1, 4), )) else: _cprint(" (No response generated)") @@ -5885,7 +5886,7 @@ class HermesCLI: title_align="left", border_style=_resp_color, box=rich_box.HORIZONTALS, - padding=(1, 2), + padding=(1, 4), )) else: _cprint(" 💬 /btw: (no response)") @@ -7648,7 +7649,7 @@ class HermesCLI: label = " ⚕ Hermes " fill = w - 2 - len(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") - _cprint(sentence.rstrip()) + _cprint(f"{_STREAM_PAD}{sentence.rstrip()}") tts_thread = threading.Thread( target=stream_tts_to_speaker, @@ -7879,7 +7880,7 @@ class HermesCLI: border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, - padding=(1, 2), + padding=(1, 4), )) diff --git a/run_agent.py b/run_agent.py index 626951b27..5f4ac68dc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6975,6 +6975,31 @@ class AIAgent: skip_pre_tool_call_hook=True, ) + @staticmethod + def _wrap_verbose(label: str, text: str, indent: str = " ") -> str: + """Word-wrap verbose tool output to fit the terminal width. + + Splits *text* on existing newlines and wraps each line individually, + preserving intentional line breaks (e.g. pretty-printed JSON). + Returns a ready-to-print string with *label* on the first line and + continuation lines indented. + """ + import shutil as _shutil + import textwrap as _tw + cols = _shutil.get_terminal_size((120, 24)).columns + wrap_width = max(40, cols - len(indent)) + out_lines: list[str] = [] + for raw_line in text.split("\n"): + if len(raw_line) <= wrap_width: + out_lines.append(raw_line) + else: + wrapped = _tw.wrap(raw_line, width=wrap_width, + break_long_words=True, + break_on_hyphens=False) + out_lines.extend(wrapped or [raw_line]) + body = ("\n" + indent).join(out_lines) + return f"{indent}{label}{body}" + def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: """Execute multiple tool calls concurrently using a thread pool. @@ -7045,7 +7070,7 @@ class AIAgent: args_str = json.dumps(args, ensure_ascii=False) if self.verbose_logging: print(f" 📞 Tool {i}: {name}({list(args.keys())})") - print(f" Args: {args_str}") + print(self._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False))) else: args_preview = args_str[:self.log_prefix_chars] + "..." if len(args_str) > self.log_prefix_chars else args_str print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}") @@ -7143,7 +7168,7 @@ class AIAgent: elif not self.quiet_mode: if self.verbose_logging: print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") - print(f" Result: {function_result}") + print(self._wrap_verbose("Result: ", function_result)) else: response_preview = function_result[:self.log_prefix_chars] + "..." if len(function_result) > self.log_prefix_chars else function_result print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") @@ -7236,7 +7261,7 @@ class AIAgent: args_str = json.dumps(function_args, ensure_ascii=False) if self.verbose_logging: print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})") - print(f" Args: {args_str}") + print(self._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False))) else: args_preview = args_str[:self.log_prefix_chars] + "..." if len(args_str) > self.log_prefix_chars else args_str print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}") @@ -7524,7 +7549,7 @@ class AIAgent: if not self.quiet_mode: if self.verbose_logging: print(f" ✅ Tool {i} completed in {tool_duration:.2f}s") - print(f" Result: {function_result}") + print(self._wrap_verbose("Result: ", function_result)) else: response_preview = function_result[:self.log_prefix_chars] + "..." if len(function_result) > self.log_prefix_chars else function_result print(f" ✅ Tool {i} completed in {tool_duration:.2f}s - {response_preview}") From 6c8930643704c1590d528ca18520d18defe7f4aa Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 17:03:47 -0700 Subject: [PATCH 121/849] fix: break stuck session resume loops after repeated restarts (#7536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session gets stuck (hung terminal, runaway tool loop) and the user restarts the gateway, the same session history loads and puts the agent right back in the stuck state. The user is trapped in a loop: restart → stuck → restart → stuck. Fix: track restart-failure counts per session using a simple JSON file (.restart_failure_counts). On each shutdown with active agents, the counter increments for those sessions. On startup, if any session has been active across 3+ consecutive restarts, it's auto-suspended — giving the user a clean slate on their next message. The counter resets to 0 when a session completes a turn successfully (response delivered), so normal sessions that happen to be active during planned restarts (/restart, hermes update) won't accumulate false counts. Implementation: - _increment_restart_failure_counts(): called during stop() when agents are active. Writes {session_key: count} to JSON file. Sessions NOT active are dropped (loop broken). - _suspend_stuck_loop_sessions(): called on startup. Reads the file, suspends sessions at threshold (3), clears the file. - _clear_restart_failure_count(): called after successful response delivery. Removes the session from the counter file. No SessionEntry schema changes. No database migration. Pure file-based tracking that naturally cleans up. Test plan: - 9 new stuck-loop tests (increment, accumulate, threshold, clear, suspend, file cleanup, edge cases) - All 28 gateway lifecycle tests pass (restart drain + auto-continue + stuck loop) --- gateway/run.py | 125 +++++++++++++++++++++++++++++++ tests/gateway/test_stuck_loop.py | 116 ++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/gateway/test_stuck_loop.py diff --git a/gateway/run.py b/gateway/run.py index a83fa2eed..d137d73c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1475,6 +1475,106 @@ class GatewayRunner: except Exception: pass + _STUCK_LOOP_THRESHOLD = 3 # restarts while active before auto-suspend + _STUCK_LOOP_FILE = ".restart_failure_counts" + + def _increment_restart_failure_counts(self, active_session_keys: set) -> None: + """Increment restart-failure counters for sessions active at shutdown. + + Persists to a JSON file so counters survive across restarts. + Sessions NOT in active_session_keys are removed (they completed + successfully, so the loop is broken). + """ + import json + + path = _hermes_home / self._STUCK_LOOP_FILE + try: + counts = json.loads(path.read_text()) if path.exists() else {} + except Exception: + counts = {} + + # Increment active sessions, remove inactive ones (loop broken) + new_counts = {} + for key in active_session_keys: + new_counts[key] = counts.get(key, 0) + 1 + # Keep any entries that are still above 0 even if not active now + # (they might become active again next restart) + + try: + path.write_text(json.dumps(new_counts)) + except Exception: + pass + + def _suspend_stuck_loop_sessions(self) -> int: + """Suspend sessions that have been active across too many restarts. + + Returns the number of sessions suspended. Called on gateway startup + AFTER suspend_recently_active() to catch the stuck-loop pattern: + session loads → agent gets stuck → gateway restarts → repeat. + """ + import json + + path = _hermes_home / self._STUCK_LOOP_FILE + if not path.exists(): + return 0 + + try: + counts = json.loads(path.read_text()) + except Exception: + return 0 + + suspended = 0 + stuck_keys = [k for k, v in counts.items() if v >= self._STUCK_LOOP_THRESHOLD] + + for session_key in stuck_keys: + try: + entry = self.session_store._entries.get(session_key) + if entry and not entry.suspended: + entry.suspended = True + suspended += 1 + logger.warning( + "Auto-suspended stuck session %s (active across %d " + "consecutive restarts — likely a stuck loop)", + session_key[:30], counts[session_key], + ) + except Exception: + pass + + if suspended: + try: + self.session_store._save() + except Exception: + pass + + # Clear the file — counters start fresh after suspension + try: + path.unlink(missing_ok=True) + except Exception: + pass + + return suspended + + def _clear_restart_failure_count(self, session_key: str) -> None: + """Clear the restart-failure counter for a session that completed OK. + + Called after a successful agent turn to signal the loop is broken. + """ + import json + + path = _hermes_home / self._STUCK_LOOP_FILE + if not path.exists(): + return + try: + counts = json.loads(path.read_text()) + if session_key in counts: + del counts[session_key] + if counts: + path.write_text(json.dumps(counts)) + else: + path.unlink(missing_ok=True) + except Exception: + pass + async def _launch_detached_restart_command(self) -> None: import shutil import subprocess @@ -1618,6 +1718,17 @@ class GatewayRunner: except Exception as e: logger.warning("Session suspension on startup failed: %s", e) + # Stuck-loop detection (#7536): if a session has been active across + # 3+ consecutive restarts, it's probably stuck in a loop (the same + # history keeps causing the agent to hang). Auto-suspend it so the + # user gets a clean slate on the next message. + try: + stuck = self._suspend_stuck_loop_sessions() + if stuck: + logger.warning("Auto-suspended %d stuck-loop session(s)", stuck) + except Exception as e: + logger.debug("Stuck-loop detection failed: %s", e) + connected_count = 0 enabled_platform_count = 0 startup_nonretryable_errors: list[str] = [] @@ -2169,6 +2280,14 @@ class GatewayRunner: "active sessions." ) + # Track sessions that were active at shutdown for stuck-loop + # detection (#7536). On each restart, the counter increments + # for sessions that were running. If a session hits the + # threshold (3 consecutive restarts while active), the next + # startup auto-suspends it — breaking the loop. + if active_agents: + self._increment_restart_failure_counts(set(active_agents.keys())) + if self._restart_requested and self._restart_via_service: self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE self._exit_reason = self._exit_reason or "Gateway restart requested" @@ -3667,6 +3786,12 @@ class GatewayRunner: _response_time, _api_calls, _resp_len, ) + # Successful turn — clear any stuck-loop counter for this session. + # This ensures the counter only accumulates across CONSECUTIVE + # restarts where the session was active (never completed). + if session_key: + self._clear_restart_failure_count(session_key) + # Surface error details when the agent failed silently (final_response=None) if not response and agent_result.get("failed"): error_detail = agent_result.get("error", "unknown error") diff --git a/tests/gateway/test_stuck_loop.py b/tests/gateway/test_stuck_loop.py new file mode 100644 index 000000000..a26f29a2b --- /dev/null +++ b/tests/gateway/test_stuck_loop.py @@ -0,0 +1,116 @@ +"""Tests for stuck-session loop detection (#7536). + +When a session is active across 3+ consecutive gateway restarts (the agent +gets stuck, gateway restarts, same session gets stuck again), the session +is auto-suspended on startup so the user gets a clean slate. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from tests.gateway.restart_test_helpers import make_restart_runner + + +@pytest.fixture +def runner_with_home(tmp_path, monkeypatch): + """Create a runner with a writable HERMES_HOME.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + runner, adapter = make_restart_runner() + return runner, tmp_path + + +class TestStuckLoopDetection: + + def test_increment_creates_file(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a", "session:b"}) + path = home / runner._STUCK_LOOP_FILE + assert path.exists() + counts = json.loads(path.read_text()) + assert counts["session:a"] == 1 + assert counts["session:b"] == 1 + + def test_increment_accumulates(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a"}) + runner._increment_restart_failure_counts({"session:a"}) + runner._increment_restart_failure_counts({"session:a"}) + counts = json.loads((home / runner._STUCK_LOOP_FILE).read_text()) + assert counts["session:a"] == 3 + + def test_increment_drops_inactive_sessions(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a", "session:b"}) + runner._increment_restart_failure_counts({"session:a"}) # b not active + counts = json.loads((home / runner._STUCK_LOOP_FILE).read_text()) + assert "session:a" in counts + assert "session:b" not in counts + + def test_suspend_at_threshold(self, runner_with_home): + runner, home = runner_with_home + # Simulate 3 restarts with session:a active each time + for _ in range(3): + runner._increment_restart_failure_counts({"session:a"}) + + # Create a mock session entry + mock_entry = MagicMock() + mock_entry.suspended = False + runner.session_store._entries = {"session:a": mock_entry} + runner.session_store._save = MagicMock() + + suspended = runner._suspend_stuck_loop_sessions() + assert suspended == 1 + assert mock_entry.suspended is True + + def test_no_suspend_below_threshold(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a"}) + runner._increment_restart_failure_counts({"session:a"}) + # Only 2 restarts — below threshold of 3 + + mock_entry = MagicMock() + mock_entry.suspended = False + runner.session_store._entries = {"session:a": mock_entry} + + suspended = runner._suspend_stuck_loop_sessions() + assert suspended == 0 + assert mock_entry.suspended is False + + def test_clear_on_success(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a", "session:b"}) + runner._clear_restart_failure_count("session:a") + + path = home / runner._STUCK_LOOP_FILE + counts = json.loads(path.read_text()) + assert "session:a" not in counts + assert "session:b" in counts + + def test_clear_removes_file_when_empty(self, runner_with_home): + runner, home = runner_with_home + runner._increment_restart_failure_counts({"session:a"}) + runner._clear_restart_failure_count("session:a") + assert not (home / runner._STUCK_LOOP_FILE).exists() + + def test_suspend_clears_file(self, runner_with_home): + runner, home = runner_with_home + for _ in range(3): + runner._increment_restart_failure_counts({"session:a"}) + + mock_entry = MagicMock() + mock_entry.suspended = False + runner.session_store._entries = {"session:a": mock_entry} + runner.session_store._save = MagicMock() + + runner._suspend_stuck_loop_sessions() + assert not (home / runner._STUCK_LOOP_FILE).exists() + + def test_no_file_no_crash(self, runner_with_home): + runner, home = runner_with_home + # No file exists — should return 0 and not crash + assert runner._suspend_stuck_loop_sessions() == 0 + # Clear on nonexistent file — should not crash + runner._clear_restart_failure_count("nonexistent") From 2a98098035ca70459570e99b6b26e1a3ca6fbd27 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 17:12:12 -0700 Subject: [PATCH 122/849] fix: hermes gateway restart waits for service to come back up (#8260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, systemd_restart() sent SIGUSR1 to the gateway, printed 'restart requested', and returned immediately. The gateway still needed to drain active agents, exit with code 75, wait for systemd's RestartSec=30, and start the new process. The user saw 'success' but the gateway was actually down for 30-60 seconds. Now the SIGUSR1 path blocks with progress feedback: Phase 1 — wait for old process to die: ⏳ User service draining active work... Polls os.kill(pid, 0) until ProcessLookupError (up to 90s) Phase 2 — wait for new process to become active: ⏳ Waiting for hermes-gateway to restart... Polls systemctl is-active + verifies new PID (up to 60s) Success: ✓ User service restarted (PID 12345) Timeout: ⚠ User service did not become active within 60s. Check status: hermes gateway status Check logs: journalctl --user -u hermes-gateway --since '2 min ago' The reload-or-restart fallback path (line 1189) already blocks because systemctl reload-or-restart is synchronous. Test plan: - Updated test to verify wait-for-restart behavior - All 118 gateway CLI tests pass --- hermes_cli/gateway.py | 57 +++++++++++++++++++++++- tests/hermes_cli/test_gateway_service.py | 37 +++++++++++---- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index fe7bb9bd8..4b13bc70f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1128,7 +1128,62 @@ def systemd_restart(system: bool = False): pid = get_running_pid() if pid is not None and _request_gateway_self_restart(pid): - print(f"✓ {_service_scope_label(system).capitalize()} service restart requested") + # SIGUSR1 sent — the gateway will drain active agents, exit with + # code 75, and systemd will restart it after RestartSec (30s). + # Wait for the old process to die and the new one to become active + # so the CLI doesn't return while the service is still restarting. + import time + scope_label = _service_scope_label(system).capitalize() + svc = get_service_name() + scope_cmd = _systemctl_cmd(system) + + # Phase 1: wait for old process to exit (drain + shutdown) + print(f"⏳ {scope_label} service draining active work...") + deadline = time.time() + 90 + while time.time() < deadline: + try: + os.kill(pid, 0) + time.sleep(1) + except (ProcessLookupError, PermissionError): + break # old process is gone + else: + print(f"⚠ Old process (PID {pid}) still alive after 90s") + + # Phase 2: wait for systemd to start the new process + print(f"⏳ Waiting for {svc} to restart...") + deadline = time.time() + 60 + while time.time() < deadline: + try: + result = subprocess.run( + scope_cmd + ["is-active", svc], + capture_output=True, text=True, timeout=5, + ) + if result.stdout.strip() == "active": + # Verify it's a NEW process, not the old one somehow + new_pid = get_running_pid() + if new_pid and new_pid != pid: + print(f"✓ {scope_label} service restarted (PID {new_pid})") + return + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + time.sleep(2) + + # Timed out — check final state + try: + result = subprocess.run( + scope_cmd + ["is-active", svc], + capture_output=True, text=True, timeout=5, + ) + if result.stdout.strip() == "active": + print(f"✓ {scope_label} service restarted") + return + except Exception: + pass + print( + f"⚠ {scope_label} service did not become active within 60s.\n" + f" Check status: {'sudo ' if system else ''}hermes gateway status\n" + f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} --since '2 min ago'" + ) return _run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90) print(f"✓ {_service_scope_label(system).capitalize()} service restarted") diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index ec35aa997..fedbdf4d1 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -452,7 +452,7 @@ class TestGatewayServiceDetection: class TestGatewaySystemServiceRouting: - def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys): + def test_systemd_restart_self_requests_graceful_restart_and_waits(self, monkeypatch, capsys): calls = [] monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) @@ -466,16 +466,37 @@ class TestGatewaySystemServiceRouting: "_request_gateway_self_restart", lambda pid: calls.append(("self", pid)) or True, ) - monkeypatch.setattr( - gateway_cli.subprocess, - "run", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("systemctl should not run")), - ) + + # Simulate: old process dies immediately, new process becomes active + kill_call_count = [0] + def fake_kill(pid, sig): + kill_call_count[0] += 1 + if kill_call_count[0] >= 2: # first call checks, second = dead + raise ProcessLookupError() + monkeypatch.setattr(os, "kill", fake_kill) + + # Simulate systemctl is-active returning "active" with a new PID + new_pid = [None] + def fake_subprocess_run(cmd, **kwargs): + if "is-active" in cmd: + result = SimpleNamespace(stdout="active\n", returncode=0) + new_pid[0] = 999 # new PID + return result + raise AssertionError(f"Unexpected systemctl call: {cmd}") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run) + # get_running_pid returns new PID after restart + pid_calls = [0] + def fake_get_pid(): + pid_calls[0] += 1 + return 999 if pid_calls[0] > 1 else 654 + monkeypatch.setattr("gateway.status.get_running_pid", fake_get_pid) gateway_cli.systemd_restart() - assert calls == [("refresh", False), ("self", 654)] - assert "restart requested" in capsys.readouterr().out.lower() + assert ("self", 654) in calls + out = capsys.readouterr().out.lower() + assert "restarted" in out def test_gateway_install_passes_system_flags(self, monkeypatch): monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) From cda64a59612f2cc6b862a112f4f276ebcd29df75 Mon Sep 17 00:00:00 2001 From: Greer Guthrie Date: Tue, 14 Apr 2026 14:42:43 -0500 Subject: [PATCH 123/849] fix(mcp): resolve toolsets from live registry --- tests/test_toolsets.py | 32 ++++++++ tests/tools/test_mcp_dynamic_discovery.py | 61 ++++----------- tests/tools/test_mcp_tool.py | 84 +++++++++++---------- tools/mcp_tool.py | 88 ++-------------------- toolsets.py | 90 ++++++++++++++++------- 5 files changed, 162 insertions(+), 193 deletions(-) diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 774bf9893..a5e2c75bb 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -116,6 +116,21 @@ class TestValidateToolset: def test_invalid(self): assert validate_toolset("nonexistent") is False + def test_mcp_alias_uses_live_registry(self, monkeypatch): + reg = ToolRegistry() + reg.register( + name="mcp_dynserver_ping", + toolset="mcp-dynserver", + schema=_make_schema("mcp_dynserver_ping", "Ping"), + handler=_dummy_handler, + ) + + monkeypatch.setattr("tools.registry.registry", reg) + + assert validate_toolset("dynserver") is True + assert validate_toolset("mcp-dynserver") is True + assert "mcp_dynserver_ping" in resolve_toolset("dynserver") + class TestGetToolsetInfo: def test_leaf(self): @@ -150,6 +165,23 @@ class TestCreateCustomToolset: del TOOLSETS["_test_custom"] +class TestRegistryOwnedToolsets: + def test_registry_membership_is_live(self, monkeypatch): + reg = ToolRegistry() + reg.register( + name="test_live_toolset_tool", + toolset="test-live-toolset", + schema=_make_schema("test_live_toolset_tool", "Live"), + handler=_dummy_handler, + ) + + monkeypatch.setattr("tools.registry.registry", reg) + + assert validate_toolset("test-live-toolset") is True + assert get_toolset("test-live-toolset")["tools"] == ["test_live_toolset_tool"] + assert resolve_toolset("test-live-toolset") == ["test_live_toolset_tool"] + + class TestToolsetConsistency: """Verify structural integrity of the built-in TOOLSETS dict.""" diff --git a/tests/tools/test_mcp_dynamic_discovery.py b/tests/tools/test_mcp_dynamic_discovery.py index c7c4ae86c..991342bd0 100644 --- a/tests/tools/test_mcp_dynamic_discovery.py +++ b/tests/tools/test_mcp_dynamic_discovery.py @@ -21,34 +21,19 @@ class TestRegisterServerTools: def mock_registry(self): return ToolRegistry() - @pytest.fixture - def mock_toolsets(self): - return { - "hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []}, - "hermes-telegram": {"tools": ["terminal"], "description": "TG", "includes": []}, - "custom-toolset": {"tools": [], "description": "Other", "includes": []}, - } - - def test_injects_hermes_toolsets(self, mock_registry, mock_toolsets): - """Tools are injected into hermes-* toolsets but not custom ones.""" + def test_exposes_live_server_aliases(self, mock_registry): + """Registered MCP tools are reachable via live raw-server aliases.""" server = MCPServerTask("my_srv") server._tools = [_make_mcp_tool("my_tool", "desc")] server.session = MagicMock() + from toolsets import resolve_toolset, validate_toolset - with patch("tools.registry.registry", mock_registry), \ - patch("toolsets.create_custom_toolset"), \ - patch.dict("toolsets.TOOLSETS", mock_toolsets, clear=True): - + with patch("tools.registry.registry", mock_registry): registered = _register_server_tools("my_srv", server, {}) - - assert "mcp_my_srv_my_tool" in registered - assert "mcp_my_srv_my_tool" in mock_registry.get_all_tool_names() - - # Injected into hermes-* toolsets - assert "mcp_my_srv_my_tool" in mock_toolsets["hermes-cli"]["tools"] - assert "mcp_my_srv_my_tool" in mock_toolsets["hermes-telegram"]["tools"] - # NOT into non-hermes toolsets - assert "mcp_my_srv_my_tool" not in mock_toolsets["custom-toolset"]["tools"] + assert "mcp_my_srv_my_tool" in registered + assert "mcp_my_srv_my_tool" in mock_registry.get_all_tool_names() + assert validate_toolset("my_srv") is True + assert "mcp_my_srv_my_tool" in resolve_toolset("my_srv") class TestRefreshTools: @@ -58,19 +43,13 @@ class TestRefreshTools: def mock_registry(self): return ToolRegistry() - @pytest.fixture - def mock_toolsets(self): - return { - "hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []}, - "hermes-telegram": {"tools": ["terminal"], "description": "TG", "includes": []}, - } - @pytest.mark.asyncio - async def test_nuke_and_repave(self, mock_registry, mock_toolsets): + async def test_nuke_and_repave(self, mock_registry): """Old tools are removed and new tools registered on refresh.""" server = MCPServerTask("live_srv") server._refresh_lock = asyncio.Lock() server._config = {} + from toolsets import resolve_toolset # Seed initial state: one old tool registered mock_registry.register( @@ -79,7 +58,6 @@ class TestRefreshTools: description="", emoji="", ) server._registered_tool_names = ["mcp_live_srv_old_tool"] - mock_toolsets["hermes-cli"]["tools"].append("mcp_live_srv_old_tool") # New tool list from server new_tool = _make_mcp_tool("new_tool", "new behavior") @@ -89,20 +67,13 @@ class TestRefreshTools: ) ) - with patch("tools.registry.registry", mock_registry), \ - patch("toolsets.create_custom_toolset"), \ - patch.dict("toolsets.TOOLSETS", mock_toolsets, clear=True): - + with patch("tools.registry.registry", mock_registry): await server._refresh_tools() - - # Old tool completely gone - assert "mcp_live_srv_old_tool" not in mock_registry.get_all_tool_names() - assert "mcp_live_srv_old_tool" not in mock_toolsets["hermes-cli"]["tools"] - - # New tool registered - assert "mcp_live_srv_new_tool" in mock_registry.get_all_tool_names() - assert "mcp_live_srv_new_tool" in mock_toolsets["hermes-cli"]["tools"] - assert server._registered_tool_names == ["mcp_live_srv_new_tool"] + assert "mcp_live_srv_old_tool" not in mock_registry.get_all_tool_names() + assert "mcp_live_srv_old_tool" not in resolve_toolset("live_srv") + assert "mcp_live_srv_new_tool" in mock_registry.get_all_tool_names() + assert "mcp_live_srv_new_tool" in resolve_toolset("live_srv") + assert server._registered_tool_names == ["mcp_live_srv_new_tool"] class TestMessageHandler: diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 43049c2c1..f5f15ea41 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -365,10 +365,13 @@ class TestDiscoverAndRegister: _servers.pop("fs", None) - def test_toolset_created(self): - """A custom toolset is created for the MCP server.""" + def test_toolset_resolves_live_from_registry(self): + """MCP toolsets resolve through the live registry without TOOLSETS mutation.""" + from tools.registry import ToolRegistry from tools.mcp_tool import _discover_and_register_server, _servers, MCPServerTask + from toolsets import resolve_toolset, validate_toolset + mock_registry = ToolRegistry() mock_tools = [_make_mcp_tool("ping", "Ping")] mock_session = MagicMock() @@ -378,16 +381,16 @@ class TestDiscoverAndRegister: server._tools = mock_tools return server - mock_create = MagicMock() with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ - patch("toolsets.create_custom_toolset", mock_create): + patch("tools.registry.registry", mock_registry): asyncio.run( _discover_and_register_server("myserver", {"command": "test"}) ) - mock_create.assert_called_once() - call_kwargs = mock_create.call_args - assert call_kwargs[1]["name"] == "mcp-myserver" or call_kwargs[0][0] == "mcp-myserver" + assert validate_toolset("myserver") is True + assert validate_toolset("mcp-myserver") is True + assert "mcp_myserver_ping" in resolve_toolset("myserver") + assert "mcp_myserver_ping" in resolve_toolset("mcp-myserver") _servers.pop("myserver", None) @@ -550,12 +553,15 @@ class TestMCPServerTask: # --------------------------------------------------------------------------- class TestToolsetInjection: - def test_mcp_tools_added_to_all_hermes_toolsets(self): - """Discovered MCP tools are dynamically injected into all hermes-* toolsets.""" + def test_mcp_tools_resolve_through_server_aliases(self): + """Discovered MCP tools resolve through raw server-name aliases.""" from tools.mcp_tool import MCPServerTask + from tools.registry import ToolRegistry + from toolsets import resolve_toolset, validate_toolset mock_tools = [_make_mcp_tool("list_files", "List files")] mock_session = MagicMock() + mock_registry = ToolRegistry() fresh_servers = {} @@ -565,43 +571,32 @@ class TestToolsetInjection: server._tools = mock_tools return server - fake_toolsets = { - "hermes-cli": {"tools": ["terminal"], "description": "CLI", "includes": []}, - "hermes-telegram": {"tools": ["terminal"], "description": "TG", "includes": []}, - "hermes-gateway": {"tools": [], "description": "GW", "includes": []}, - "non-hermes": {"tools": [], "description": "other", "includes": []}, - } fake_config = {"fs": {"command": "npx", "args": []}} with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ patch("tools.mcp_tool._servers", fresh_servers), \ patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ - patch("toolsets.TOOLSETS", fake_toolsets): + patch("tools.registry.registry", mock_registry): from tools.mcp_tool import discover_mcp_tools result = discover_mcp_tools() - assert "mcp_fs_list_files" in result - # All hermes-* toolsets get injection - assert "mcp_fs_list_files" in fake_toolsets["hermes-cli"]["tools"] - assert "mcp_fs_list_files" in fake_toolsets["hermes-telegram"]["tools"] - assert "mcp_fs_list_files" in fake_toolsets["hermes-gateway"]["tools"] - # Non-hermes toolset should NOT get injection - assert "mcp_fs_list_files" not in fake_toolsets["non-hermes"]["tools"] - # Original tools preserved - assert "terminal" in fake_toolsets["hermes-cli"]["tools"] - # Server name becomes a standalone toolset - assert "fs" in fake_toolsets - assert "mcp_fs_list_files" in fake_toolsets["fs"]["tools"] - assert fake_toolsets["fs"]["description"].startswith("MCP server '") + assert "mcp_fs_list_files" in result + assert validate_toolset("fs") is True + assert validate_toolset("mcp-fs") is True + assert "mcp_fs_list_files" in resolve_toolset("fs") + assert "mcp_fs_list_files" in resolve_toolset("mcp-fs") def test_server_toolset_skips_builtin_collision(self): - """MCP server named after a built-in toolset shouldn't overwrite it.""" + """MCP raw aliases never overwrite a built-in toolset name.""" from tools.mcp_tool import MCPServerTask + from tools.registry import ToolRegistry + from toolsets import resolve_toolset, validate_toolset mock_tools = [_make_mcp_tool("run", "Run command")] mock_session = MagicMock() fresh_servers = {} + mock_registry = ToolRegistry() async def fake_connect(name, config): server = MCPServerTask(name) @@ -620,12 +615,15 @@ class TestToolsetInjection: patch("tools.mcp_tool._servers", fresh_servers), \ patch("tools.mcp_tool._load_mcp_config", return_value=fake_config), \ patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("tools.registry.registry", mock_registry), \ patch("toolsets.TOOLSETS", fake_toolsets): from tools.mcp_tool import discover_mcp_tools discover_mcp_tools() - # Built-in toolset preserved — description unchanged - assert fake_toolsets["terminal"]["description"] == "Terminal tools" + assert fake_toolsets["terminal"]["description"] == "Terminal tools" + assert "mcp_terminal_run" not in resolve_toolset("terminal") + assert validate_toolset("mcp-terminal") is True + assert "mcp_terminal_run" in resolve_toolset("mcp-terminal") def test_server_connection_failure_skipped(self): """If one server fails to connect, others still proceed.""" @@ -3038,14 +3036,22 @@ class TestSanitizeMcpNameComponent: assert "/" not in name assert "." not in name - def test_slash_in_sync_mcp_toolsets(self): - """_sync_mcp_toolsets uses sanitize consistently with _convert_mcp_schema.""" - from tools.mcp_tool import sanitize_mcp_name_component + def test_slash_in_server_alias_resolution(self): + """Server names with slashes resolve through their live MCP alias.""" + from tools.registry import ToolRegistry + from toolsets import resolve_toolset, validate_toolset - # Verify the prefix generation matches what _convert_mcp_schema produces - server_name = "ai.exa/exa" - safe_prefix = f"mcp_{sanitize_mcp_name_component(server_name)}_" - assert safe_prefix == "mcp_ai_exa_exa_" + reg = ToolRegistry() + reg.register( + name="mcp_ai_exa_exa_search", + toolset="mcp-ai.exa/exa", + schema={"name": "mcp_ai_exa_exa_search", "description": "Search", "parameters": {"type": "object", "properties": {}}}, + handler=lambda *_args, **_kwargs: "{}", + ) + + with patch("tools.registry.registry", reg): + assert validate_toolset("ai.exa/exa") is True + assert "mcp_ai_exa_exa_search" in resolve_toolset("ai.exa/exa") # --------------------------------------------------------------------------- diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index d6bdc89fa..263e4408f 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -846,8 +846,7 @@ class MCPServerTask: After the initial ``await`` (list_tools), all mutations are synchronous — atomic from the event loop's perspective. """ - from tools.registry import registry, tool_error - from toolsets import TOOLSETS + from tools.registry import registry async with self._refresh_lock: # Capture old tool names for change diff @@ -857,16 +856,11 @@ class MCPServerTask: tools_result = await self.session.list_tools() new_mcp_tools = tools_result.tools if hasattr(tools_result, "tools") else [] - # 2. Remove old tools from hermes-* umbrella toolsets - for ts_name, ts in TOOLSETS.items(): - if ts_name.startswith("hermes-"): - ts["tools"] = [t for t in ts["tools"] if t not in self._registered_tool_names] - - # 3. Deregister old tools from the central registry + # 2. Deregister old tools from the central registry for prefixed_name in self._registered_tool_names: registry.deregister(prefixed_name) - # 4. Re-register with fresh tool list + # 3. Re-register with fresh tool list self._tools = new_mcp_tools self._registered_tool_names = _register_server_tools( self.name, self, self._config @@ -1671,57 +1665,6 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: } -def _sync_mcp_toolsets(server_names: Optional[List[str]] = None) -> None: - """Expose each MCP server as a standalone toolset and inject into hermes-* sets. - - Creates a real toolset entry in TOOLSETS for each server name (e.g. - TOOLSETS["github"] = {"tools": ["mcp_github_list_files", ...]}). This - makes raw server names resolvable in platform_toolsets overrides. - - Also injects all MCP tools into hermes-* umbrella toolsets for the - default behavior. - - Skips server names that collide with built-in toolsets. - """ - from toolsets import TOOLSETS - - if server_names is None: - server_names = list(_load_mcp_config().keys()) - - existing = _existing_tool_names() - all_mcp_tools: List[str] = [] - - for server_name in server_names: - safe_prefix = f"mcp_{sanitize_mcp_name_component(server_name)}_" - server_tools = sorted( - t for t in existing if t.startswith(safe_prefix) - ) - all_mcp_tools.extend(server_tools) - - # Don't overwrite a built-in toolset that happens to share the name. - existing_ts = TOOLSETS.get(server_name) - if existing_ts and not str(existing_ts.get("description", "")).startswith("MCP server '"): - logger.warning( - "Skipping MCP toolset alias '%s' — a built-in toolset already uses that name", - server_name, - ) - continue - - TOOLSETS[server_name] = { - "description": f"MCP server '{server_name}' tools", - "tools": server_tools, - "includes": [], - } - - # Also inject into hermes-* umbrella toolsets for default behavior. - for ts_name, ts in TOOLSETS.items(): - if not ts_name.startswith("hermes-"): - continue - for tool_name in all_mcp_tools: - if tool_name not in ts["tools"]: - ts["tools"].append(tool_name) - - def _build_utility_schemas(server_name: str) -> List[dict]: """Build schemas for the MCP utility tools (resources & prompts). @@ -1874,16 +1817,16 @@ def _existing_tool_names() -> List[str]: def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> List[str]: """Register tools from an already-connected server into the registry. - Handles include/exclude filtering, utility tools, toolset creation, - and hermes-* umbrella toolset injection. + Handles include/exclude filtering and utility tools. Toolset resolution + for ``mcp-{server}`` and raw server-name aliases is derived from the live + registry, rather than mutating ``toolsets.TOOLSETS`` at runtime. Used by both initial discovery and dynamic refresh (list_changed). Returns: List of registered prefixed tool names. """ - from tools.registry import registry, tool_error - from toolsets import create_custom_toolset, TOOLSETS + from tools.registry import registry registered_names: List[str] = [] toolset_name = f"mcp-{name}" @@ -1973,20 +1916,6 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li ) registered_names.append(util_name) - # Create a custom toolset so these tools are discoverable - if registered_names: - create_custom_toolset( - name=toolset_name, - description=f"MCP tools from {name} server", - tools=registered_names, - ) - # Inject into hermes-* umbrella toolsets for default behavior - for ts_name, ts in TOOLSETS.items(): - if ts_name.startswith("hermes-"): - for tool_name in registered_names: - if tool_name not in ts["tools"]: - ts["tools"].append(tool_name) - return registered_names @@ -2049,7 +1978,6 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: } if not new_servers: - _sync_mcp_toolsets(list(servers.keys())) return _existing_tool_names() # Start the background event loop for MCP connections @@ -2080,8 +2008,6 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: # The outer timeout is generous: 120s total for parallel discovery. _run_on_mcp_loop(_discover_all(), timeout=120) - _sync_mcp_toolsets(list(servers.keys())) - # Log a summary so ACP callers get visibility into what was registered. with _lock: connected = [n for n in new_servers if n in _servers] diff --git a/toolsets.py b/toolsets.py index 2e7a0a92a..7c843fbfb 100644 --- a/toolsets.py +++ b/toolsets.py @@ -409,8 +409,31 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]: Dict: Toolset definition with description, tools, and includes None: If toolset not found """ - # Return toolset definition - return TOOLSETS.get(name) + toolset = TOOLSETS.get(name) + if toolset: + return toolset + + try: + from tools.registry import registry + except Exception: + return None + + registry_toolset = name + description = f"Plugin toolset: {name}" + + if name not in _get_plugin_toolset_names(): + registry_toolset = _get_mcp_toolset_aliases().get(name) + if not registry_toolset: + return None + description = f"MCP server '{name}' tools" + elif name.startswith("mcp-"): + description = f"MCP server '{name[4:]}' tools" + + return { + "description": description, + "tools": registry.get_tool_names_for_toolset(registry_toolset), + "includes": [], + } def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: @@ -438,7 +461,7 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: # Use a fresh visited set per branch to avoid cross-branch contamination resolved = resolve_toolset(toolset_name, visited.copy()) all_tools.update(resolved) - return list(all_tools) + return sorted(all_tools) # Check for cycles / already-resolved (diamond deps). # Silently return [] — either this is a diamond (not a bug, tools already @@ -449,15 +472,8 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: visited.add(name) # Get toolset definition - toolset = TOOLSETS.get(name) + toolset = get_toolset(name) if not toolset: - # Fall back to tool registry for plugin-provided toolsets - if name in _get_plugin_toolset_names(): - try: - from tools.registry import registry - return registry.get_tool_names_for_toolset(name) - except Exception: - pass return [] # Collect direct tools @@ -470,7 +486,7 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: included_tools = resolve_toolset(included_name, visited) tools.update(included_tools) - return list(tools) + return sorted(tools) def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: @@ -489,7 +505,7 @@ def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: tools = resolve_toolset(name) all_tools.update(tools) - return list(all_tools) + return sorted(all_tools) def _get_plugin_toolset_names() -> Set[str]: @@ -509,6 +525,18 @@ def _get_plugin_toolset_names() -> Set[str]: return set() +def _get_mcp_toolset_aliases() -> Dict[str, str]: + """Map raw MCP server names to their live registry toolset names.""" + aliases = {} + for toolset_name in _get_plugin_toolset_names(): + if not toolset_name.startswith("mcp-"): + continue + alias = toolset_name[4:] + if alias and alias not in TOOLSETS: + aliases[alias] = toolset_name + return aliases + + def get_all_toolsets() -> Dict[str, Dict[str, Any]]: """ Get all available toolsets with their definitions. @@ -518,19 +546,18 @@ def get_all_toolsets() -> Dict[str, Dict[str, Any]]: Returns: Dict: All toolset definitions """ - result = TOOLSETS.copy() - # Add plugin-provided toolsets (synthetic entries) + result = dict(TOOLSETS) for ts_name in _get_plugin_toolset_names(): - if ts_name not in result: - try: - from tools.registry import registry - tools = registry.get_tool_names_for_toolset(ts_name) - result[ts_name] = { - "description": f"Plugin toolset: {ts_name}", - "tools": tools, - } - except Exception: - pass + display_name = ts_name + if ts_name.startswith("mcp-"): + alias = ts_name[4:] + if alias and alias not in TOOLSETS: + display_name = alias + if display_name in result: + continue + toolset = get_toolset(display_name) + if toolset: + result[display_name] = toolset return result @@ -544,7 +571,13 @@ def get_toolset_names() -> List[str]: List[str]: List of toolset names """ names = set(TOOLSETS.keys()) - names |= _get_plugin_toolset_names() + for ts_name in _get_plugin_toolset_names(): + if ts_name.startswith("mcp-"): + alias = ts_name[4:] + if alias and alias not in TOOLSETS: + names.add(alias) + continue + names.add(ts_name) return sorted(names) @@ -565,8 +598,9 @@ def validate_toolset(name: str) -> bool: return True if name in TOOLSETS: return True - # Check tool registry for plugin-provided toolsets - return name in _get_plugin_toolset_names() + if name in _get_plugin_toolset_names(): + return True + return name in _get_mcp_toolset_aliases() def create_custom_toolset( From c10fea8d264e3289c4c8f5c0b35468aca849b123 Mon Sep 17 00:00:00 2001 From: Greer Guthrie Date: Tue, 14 Apr 2026 15:12:45 -0500 Subject: [PATCH 124/849] fix(mcp): make server aliases explicit --- tests/test_toolsets.py | 1 + tests/tools/test_mcp_dynamic_discovery.py | 19 ++++++++ tests/tools/test_mcp_tool.py | 55 ++++++++++++++++++----- tools/mcp_tool.py | 8 ++++ tools/registry.py | 35 +++++++++++++-- toolsets.py | 51 ++++++++++++--------- 6 files changed, 133 insertions(+), 36 deletions(-) diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index a5e2c75bb..9a982bb5b 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -124,6 +124,7 @@ class TestValidateToolset: schema=_make_schema("mcp_dynserver_ping", "Ping"), handler=_dummy_handler, ) + reg.register_toolset_alias("dynserver", "mcp-dynserver") monkeypatch.setattr("tools.registry.registry", reg) diff --git a/tests/tools/test_mcp_dynamic_discovery.py b/tests/tools/test_mcp_dynamic_discovery.py index 991342bd0..891770319 100644 --- a/tests/tools/test_mcp_dynamic_discovery.py +++ b/tests/tools/test_mcp_dynamic_discovery.py @@ -136,6 +136,25 @@ class TestDeregister: # bar still in ts1, so check should remain assert "ts1" in reg._toolset_checks + def test_removes_toolset_alias_when_last_tool_is_removed(self): + reg = ToolRegistry() + reg.register(name="foo", toolset="mcp-srv", schema={}, handler=lambda x: x) + reg.register_toolset_alias("srv", "mcp-srv") + + reg.deregister("foo") + + assert reg.get_toolset_alias_target("srv") is None + + def test_preserves_toolset_alias_while_toolset_still_exists(self): + reg = ToolRegistry() + reg.register(name="foo", toolset="mcp-srv", schema={}, handler=lambda x: x) + reg.register(name="bar", toolset="mcp-srv", schema={}, handler=lambda x: x) + reg.register_toolset_alias("srv", "mcp-srv") + + reg.deregister("foo") + + assert reg.get_toolset_alias_target("srv") == "mcp-srv" + def test_noop_for_unknown_tool(self): reg = ToolRegistry() reg.deregister("nonexistent") # Should not raise diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index f5f15ea41..da46348ea 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -184,11 +184,7 @@ class TestToolHandler: def _patch_mcp_loop(self, coro_side_effect=None): """Return a patch for _run_on_mcp_loop that runs the coroutine directly.""" def fake_run(coro, timeout=30): - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() + return asyncio.run(coro) if coro_side_effect: return patch("tools.mcp_tool._run_on_mcp_loop", side_effect=coro_side_effect) return patch("tools.mcp_tool._run_on_mcp_loop", side_effect=fake_run) @@ -774,6 +770,42 @@ class TestShutdown: assert len(_servers) == 0 mock_server.shutdown.assert_called_once() + def test_shutdown_deregisters_registered_tools(self): + """shutdown_mcp_servers removes MCP tools and their raw alias.""" + import tools.mcp_tool as mcp_mod + from tools.mcp_tool import MCPServerTask, shutdown_mcp_servers, _servers + from tools.registry import registry + from toolsets import resolve_toolset, validate_toolset + + _servers.clear() + registry.register( + name="mcp_test_ping", + toolset="mcp-test", + schema={ + "name": "mcp_test_ping", + "description": "Ping", + "parameters": {"type": "object", "properties": {}}, + }, + handler=lambda *_args, **_kwargs: "{}", + ) + registry.register_toolset_alias("test", "mcp-test") + + server = MCPServerTask("test") + server._registered_tool_names = ["mcp_test_ping"] + _servers["test"] = server + + mcp_mod._ensure_mcp_loop() + try: + assert validate_toolset("test") is True + assert "mcp_test_ping" in resolve_toolset("test") + shutdown_mcp_servers() + finally: + mcp_mod._mcp_loop = None + mcp_mod._mcp_thread = None + + assert "mcp_test_ping" not in registry.get_all_tool_names() + assert validate_toolset("test") is False + def test_shutdown_handles_errors(self): """shutdown_mcp_servers handles errors during close gracefully.""" import tools.mcp_tool as mcp_mod @@ -1177,7 +1209,11 @@ class TestConfigurableTimeouts: try: handler = _make_tool_handler("test_srv", "my_tool", 180) with patch("tools.mcp_tool._run_on_mcp_loop") as mock_run: - mock_run.return_value = json.dumps({"result": "ok"}) + def fake_run(coro, timeout=30): + coro.close() + return json.dumps({"result": "ok"}) + + mock_run.side_effect = fake_run handler({}) # Verify timeout=180 was passed call_kwargs = mock_run.call_args @@ -1277,11 +1313,7 @@ class TestUtilityHandlers: def _patch_mcp_loop(self): """Return a patch for _run_on_mcp_loop that runs the coroutine directly.""" def fake_run(coro, timeout=30): - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() + return asyncio.run(coro) return patch("tools.mcp_tool._run_on_mcp_loop", side_effect=fake_run) # -- list_resources -- @@ -3048,6 +3080,7 @@ class TestSanitizeMcpNameComponent: schema={"name": "mcp_ai_exa_exa_search", "description": "Search", "parameters": {"type": "object", "properties": {}}}, handler=lambda *_args, **_kwargs: "{}", ) + reg.register_toolset_alias("ai.exa/exa", "mcp-ai.exa/exa") with patch("tools.registry.registry", reg): assert validate_toolset("ai.exa/exa") is True diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 263e4408f..fa8b945ca 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1138,6 +1138,8 @@ class MCPServerTask: async def shutdown(self): """Signal the Task to exit and wait for clean resource teardown.""" + from tools.registry import registry + self._shutdown_event.set() if self._task and not self._task.done(): try: @@ -1152,6 +1154,9 @@ class MCPServerTask: await self._task except asyncio.CancelledError: pass + for tool_name in list(getattr(self, "_registered_tool_names", [])): + registry.deregister(tool_name) + self._registered_tool_names = [] self.session = None @@ -1916,6 +1921,9 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li ) registered_names.append(util_name) + if registered_names: + registry.register_toolset_alias(name, toolset_name) + return registered_names diff --git a/tools/registry.py b/tools/registry.py index b7351cb16..ebda77807 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -52,6 +52,7 @@ class ToolRegistry: def __init__(self): self._tools: Dict[str, ToolEntry] = {} self._toolset_checks: Dict[str, Callable] = {} + self._toolset_aliases: Dict[str, str] = {} # MCP dynamic refresh can mutate the registry while other threads are # reading tool metadata, so keep mutations serialized and readers on # stable snapshots. @@ -96,6 +97,27 @@ class ToolRegistry: if entry.toolset == toolset ) + def register_toolset_alias(self, alias: str, toolset: str) -> None: + """Register an explicit alias for a canonical toolset name.""" + with self._lock: + existing = self._toolset_aliases.get(alias) + if existing and existing != toolset: + logger.warning( + "Toolset alias collision: '%s' (%s) overwritten by %s", + alias, existing, toolset, + ) + self._toolset_aliases[alias] = toolset + + def get_registered_toolset_aliases(self) -> Dict[str, str]: + """Return a snapshot of ``{alias: canonical_toolset}`` mappings.""" + with self._lock: + return dict(self._toolset_aliases) + + def get_toolset_alias_target(self, alias: str) -> Optional[str]: + """Return the canonical toolset name for an alias, or None.""" + with self._lock: + return self._toolset_aliases.get(alias) + # ------------------------------------------------------------------ # Registration # ------------------------------------------------------------------ @@ -164,11 +186,18 @@ class ToolRegistry: entry = self._tools.pop(name, None) if entry is None: return - # Drop the toolset check if this was the last tool in that toolset - if entry.toolset in self._toolset_checks and not any( + # Drop the toolset check and aliases if this was the last tool in + # that toolset. + toolset_still_exists = any( e.toolset == entry.toolset for e in self._tools.values() - ): + ) + if not toolset_still_exists: self._toolset_checks.pop(entry.toolset, None) + self._toolset_aliases = { + alias: target + for alias, target in self._toolset_aliases.items() + if target != entry.toolset + } logger.debug("Deregistered tool: %s", name) # ------------------------------------------------------------------ diff --git a/toolsets.py b/toolsets.py index 7c843fbfb..09ee8de09 100644 --- a/toolsets.py +++ b/toolsets.py @@ -420,14 +420,22 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]: registry_toolset = name description = f"Plugin toolset: {name}" + alias_target = registry.get_toolset_alias_target(name) if name not in _get_plugin_toolset_names(): - registry_toolset = _get_mcp_toolset_aliases().get(name) + registry_toolset = alias_target if not registry_toolset: return None description = f"MCP server '{name}' tools" - elif name.startswith("mcp-"): - description = f"MCP server '{name[4:]}' tools" + else: + reverse_aliases = { + canonical: alias + for alias, canonical in _get_registry_toolset_aliases().items() + if alias not in TOOLSETS + } + alias = reverse_aliases.get(name) + if alias: + description = f"MCP server '{alias}' tools" return { "description": description, @@ -525,16 +533,13 @@ def _get_plugin_toolset_names() -> Set[str]: return set() -def _get_mcp_toolset_aliases() -> Dict[str, str]: - """Map raw MCP server names to their live registry toolset names.""" - aliases = {} - for toolset_name in _get_plugin_toolset_names(): - if not toolset_name.startswith("mcp-"): - continue - alias = toolset_name[4:] - if alias and alias not in TOOLSETS: - aliases[alias] = toolset_name - return aliases +def _get_registry_toolset_aliases() -> Dict[str, str]: + """Return explicit toolset aliases registered in the live registry.""" + try: + from tools.registry import registry + return registry.get_registered_toolset_aliases() + except Exception: + return {} def get_all_toolsets() -> Dict[str, Dict[str, Any]]: @@ -547,12 +552,13 @@ def get_all_toolsets() -> Dict[str, Dict[str, Any]]: Dict: All toolset definitions """ result = dict(TOOLSETS) + aliases = _get_registry_toolset_aliases() for ts_name in _get_plugin_toolset_names(): display_name = ts_name - if ts_name.startswith("mcp-"): - alias = ts_name[4:] - if alias and alias not in TOOLSETS: + for alias, canonical in aliases.items(): + if canonical == ts_name and alias not in TOOLSETS: display_name = alias + break if display_name in result: continue toolset = get_toolset(display_name) @@ -571,13 +577,14 @@ def get_toolset_names() -> List[str]: List[str]: List of toolset names """ names = set(TOOLSETS.keys()) + aliases = _get_registry_toolset_aliases() for ts_name in _get_plugin_toolset_names(): - if ts_name.startswith("mcp-"): - alias = ts_name[4:] - if alias and alias not in TOOLSETS: + for alias, canonical in aliases.items(): + if canonical == ts_name and alias not in TOOLSETS: names.add(alias) - continue - names.add(ts_name) + break + else: + names.add(ts_name) return sorted(names) @@ -600,7 +607,7 @@ def validate_toolset(name: str) -> bool: return True if name in _get_plugin_toolset_names(): return True - return name in _get_mcp_toolset_aliases() + return name in _get_registry_toolset_aliases() def create_custom_toolset( From 498cb7a0fc2643ccff6be942759cab935aa805d4 Mon Sep 17 00:00:00 2001 From: Greer Guthrie Date: Tue, 14 Apr 2026 15:45:09 -0500 Subject: [PATCH 125/849] chore(release): map greer guthrie attribution --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 1d80499ca..fb7924640 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -95,6 +95,7 @@ AUTHOR_MAP = { "vincentcharlebois@gmail.com": "vincentcharlebois", "aryan@synvoid.com": "aryansingh", "johnsonblake1@gmail.com": "blakejohnson", + "greer.guthrie@gmail.com": "g-guthrie", "kennyx102@gmail.com": "bobashopcashier", "shokatalishaikh95@gmail.com": "areu01or00", "bryan@intertwinesys.com": "bryanyoung", From 4610551d742e3adb8c01bf48ca15e2188b396061 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 17:18:53 -0700 Subject: [PATCH 126/849] fix: update stale comment referencing removed _sync_mcp_toolsets --- cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index d679b24b5..b8f34c9bb 100644 --- a/cli.py +++ b/cli.py @@ -1713,9 +1713,9 @@ class HermesCLI: # Parse and validate toolsets self.enabled_toolsets = toolsets if toolsets and "all" not in toolsets and "*" not in toolsets: - # Validate each toolset — MCP server names are added by - # _get_platform_tools() but aren't registered in TOOLSETS yet - # (that happens later in _sync_mcp_toolsets), so exclude them. + # Validate each toolset — MCP server names are resolved via + # live registry aliases (registered during discover_mcp_tools), + # but discovery hasn't run yet at this point, so exclude them. mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys()) invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names] if invalid: From 4cbf54fb332a85550f43118acc89277516c66ac7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 19:38:04 -0500 Subject: [PATCH 127/849] 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 873b703d9..b85c0ad94 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 ff2507ac6..7daa876ac 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 ab417fcae..f52bf0636 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 5107f41d9..ca77058d6 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 d9057725f..5c9e62b46 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 ca89182d7..38e527635 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 703f33ec2..e9687ce7c 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 1c74872c1..aae199324 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 677f1227c37db376ed12136e286772e5cc65605a Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Tue, 14 Apr 2026 19:23:44 +0530 Subject: [PATCH 128/849] =?UTF-8?q?fix:=20remove=20@staticmethod=20from=20?= =?UTF-8?q?=5Fcontext=5Fcompletions=20=E2=80=94=20crashes=20on=20@=20menti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #9467 added a call to self._fuzzy_file_completions() inside _context_completions(), but the method was still decorated with @staticmethod and didn't receive self. Every @ mention in the input triggers 'name self is not defined' from prompt_toolkit's async completer, spamming the error on every keystroke. Fix: remove @staticmethod, add self parameter. The method already uses self._fuzzy_file_completions() and self._get_project_files() via that call chain, so it was never meant to stay static after the fuzzy search feature was added. --- hermes_cli/commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e08aacf64..516392bd1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -844,8 +844,7 @@ class SlashCommandCompleter(Completer): return None return word - @staticmethod - def _context_completions(word: str, limit: int = 30): + def _context_completions(self, word: str, limit: int = 30): """Yield Claude Code-style @ context completions. Bare ``@`` or ``@partial`` shows static references and matching From da528a8207d6badafa00bf413b365c9bc1ce1acc Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 17:17:15 -0700 Subject: [PATCH 129/849] fix: detect and strip non-ASCII characters from API keys (#6843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys containing Unicode lookalike characters (e.g. ʋ U+028B instead of v) cause UnicodeEncodeError when httpx encodes the Authorization header as ASCII. This commonly happens when users copy-paste keys from PDFs, rich-text editors, or web pages with decorative fonts. Three layers of defense: 1. **Save-time validation** (hermes_cli/config.py): _check_non_ascii_credential() strips non-ASCII from credential values when saving to .env, with a clear warning explaining the issue. 2. **Load-time sanitization** (hermes_cli/env_loader.py): _sanitize_loaded_credentials() strips non-ASCII from credential env vars (those ending in _API_KEY, _TOKEN, _SECRET, _KEY) after dotenv loads them, so the rest of the codebase never sees non-ASCII keys. 3. **Runtime recovery** (run_agent.py): The UnicodeEncodeError recovery block now also sanitizes self.api_key and self._client_kwargs['api_key'], fixing the gap where message/tool sanitization succeeded but the API key still caused httpx to fail on the Authorization header. Also: hermes_logging.py RotatingFileHandler now explicitly sets encoding='utf-8' instead of relying on locale default (defensive hardening for ASCII-locale systems). --- hermes_cli/config.py | 43 ++++++++++ hermes_cli/env_loader.py | 29 +++++++ hermes_logging.py | 1 + run_agent.py | 23 +++++ tests/hermes_cli/test_non_ascii_credential.py | 83 +++++++++++++++++++ tests/run_agent/test_unicode_ascii_codec.py | 27 ++++++ 6 files changed, 206 insertions(+) create mode 100644 tests/hermes_cli/test_non_ascii_credential.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d121bc517..d06338aa1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2766,6 +2766,47 @@ def sanitize_env_file() -> int: return fixes +def _check_non_ascii_credential(key: str, value: str) -> str: + """Warn and strip non-ASCII characters from credential values. + + API keys and tokens must be pure ASCII — they are sent as HTTP header + values which httpx/httpcore encode as ASCII. Non-ASCII characters + (commonly introduced by copy-pasting from rich-text editors or PDFs + that substitute lookalike Unicode glyphs for ASCII letters) cause + ``UnicodeEncodeError: 'ascii' codec can't encode character`` at + request time. + + Returns the sanitized (ASCII-only) value. Prints a warning if any + non-ASCII characters were found and removed. + """ + try: + value.encode("ascii") + return value # all ASCII — nothing to do + except UnicodeEncodeError: + pass + + # Build a readable list of the offending characters + bad_chars: list[str] = [] + for i, ch in enumerate(value): + if ord(ch) > 127: + bad_chars.append(f" position {i}: {ch!r} (U+{ord(ch):04X})") + sanitized = value.encode("ascii", errors="ignore").decode("ascii") + + import sys + print( + f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n" + f" This usually happens when copy-pasting from a PDF, rich-text editor,\n" + f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n" + f"\n" + + "\n".join(f" {line}" for line in bad_chars[:5]) + + ("\n ... and more" if len(bad_chars) > 5 else "") + + f"\n\n The non-ASCII characters have been stripped automatically.\n" + f" If authentication fails, re-copy the key from the provider's dashboard.\n", + file=sys.stderr, + ) + return sanitized + + def save_env_value(key: str, value: str): """Save or update a value in ~/.hermes/.env.""" if is_managed(): @@ -2774,6 +2815,8 @@ def save_env_value(key: str, value: str): if not _ENV_VAR_NAME_RE.match(key): raise ValueError(f"Invalid environment variable name: {key!r}") value = value.replace("\n", "").replace("\r", "") + # API keys / tokens must be ASCII — strip non-ASCII with a warning. + value = _check_non_ascii_credential(key, value) ensure_hermes_home() env_path = get_env_path() diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 8d6a1449d..853f0d262 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -8,11 +8,40 @@ from pathlib import Path from dotenv import load_dotenv +# Env var name suffixes that indicate credential values. These are the +# only env vars whose values we sanitize on load — we must not silently +# alter arbitrary user env vars, but credentials are known to require +# pure ASCII (they become HTTP header values). +_CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY") + + +def _sanitize_loaded_credentials() -> None: + """Strip non-ASCII characters from credential env vars in os.environ. + + Called after dotenv loads so the rest of the codebase never sees + non-ASCII API keys. Only touches env vars whose names end with + known credential suffixes (``_API_KEY``, ``_TOKEN``, etc.). + """ + for key, value in list(os.environ.items()): + if not any(key.endswith(suffix) for suffix in _CREDENTIAL_SUFFIXES): + continue + try: + value.encode("ascii") + except UnicodeEncodeError: + os.environ[key] = value.encode("ascii", errors="ignore").decode("ascii") + + def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None: try: load_dotenv(dotenv_path=path, override=override, encoding="utf-8") except UnicodeDecodeError: load_dotenv(dotenv_path=path, override=override, encoding="latin-1") + # Strip non-ASCII characters from credential env vars that were just + # loaded. API keys must be pure ASCII since they're sent as HTTP + # header values (httpx encodes headers as ASCII). Non-ASCII chars + # typically come from copy-pasting keys from PDFs or rich-text editors + # that substitute Unicode lookalike glyphs (e.g. ʋ U+028B for v). + _sanitize_loaded_credentials() def _sanitize_env_file_if_needed(path: Path) -> None: diff --git a/hermes_logging.py b/hermes_logging.py index dbef21328..0ebc450a2 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -358,6 +358,7 @@ def _add_rotating_handler( path.parent.mkdir(parents=True, exist_ok=True) handler = _ManagedRotatingFileHandler( str(path), maxBytes=max_bytes, backupCount=backup_count, + encoding="utf-8", ) handler.setLevel(level) handler.setFormatter(formatter) diff --git a/run_agent.py b/run_agent.py index 5f4ac68dc..0d6be24d0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8987,12 +8987,35 @@ class AIAgent: if isinstance(_default_headers, dict): _headers_sanitized = _sanitize_structure_non_ascii(_default_headers) + # Sanitize the API key — non-ASCII characters in + # credentials (e.g. ʋ instead of v from a bad + # copy-paste) cause httpx to fail when encoding + # the Authorization header as ASCII. This is the + # most common cause of persistent UnicodeEncodeError + # that survives message/tool sanitization (#6843). + _credential_sanitized = False + _raw_key = getattr(self, "api_key", None) or "" + if _raw_key: + _clean_key = _strip_non_ascii(_raw_key) + if _clean_key != _raw_key: + self.api_key = _clean_key + if isinstance(getattr(self, "_client_kwargs", None), dict): + self._client_kwargs["api_key"] = _clean_key + _credential_sanitized = True + self._vprint( + f"{self.log_prefix}⚠️ API key contained non-ASCII characters " + f"(bad copy-paste?) — stripped them. If auth fails, " + f"re-copy the key from your provider's dashboard.", + force=True, + ) + if ( _messages_sanitized or _prefill_sanitized or _tools_sanitized or _system_sanitized or _headers_sanitized + or _credential_sanitized ): self._unicode_sanitization_passes += 1 self._vprint( diff --git a/tests/hermes_cli/test_non_ascii_credential.py b/tests/hermes_cli/test_non_ascii_credential.py new file mode 100644 index 000000000..fe39335eb --- /dev/null +++ b/tests/hermes_cli/test_non_ascii_credential.py @@ -0,0 +1,83 @@ +"""Tests for non-ASCII credential detection and sanitization. + +Covers the fix for issue #6843 — API keys containing Unicode lookalike +characters (e.g. ʋ U+028B instead of v) cause UnicodeEncodeError when +httpx tries to encode the Authorization header as ASCII. +""" + +import os +import sys +import tempfile + +import pytest + +from hermes_cli.config import _check_non_ascii_credential + + +class TestCheckNonAsciiCredential: + """Tests for _check_non_ascii_credential().""" + + def test_ascii_key_unchanged(self): + key = "sk-proj-" + "a" * 100 + result = _check_non_ascii_credential("TEST_API_KEY", key) + assert result == key + + def test_strips_unicode_v_lookalike(self, capsys): + """The exact scenario from issue #6843: ʋ instead of v.""" + key = "sk-proj-abc" + "ʋ" + "def" # \u028b + result = _check_non_ascii_credential("OPENROUTER_API_KEY", key) + assert result == "sk-proj-abcdef" + assert "ʋ" not in result + # Should print a warning + captured = capsys.readouterr() + assert "non-ASCII" in captured.err + + def test_strips_multiple_non_ascii(self, capsys): + key = "sk-proj-aʋbécd" + result = _check_non_ascii_credential("OPENAI_API_KEY", key) + assert result == "sk-proj-abcd" + captured = capsys.readouterr() + assert "U+028B" in captured.err # reports the char + + def test_empty_key(self): + result = _check_non_ascii_credential("TEST_KEY", "") + assert result == "" + + def test_all_ascii_no_warning(self, capsys): + result = _check_non_ascii_credential("KEY", "all-ascii-value-123") + assert result == "all-ascii-value-123" + captured = capsys.readouterr() + assert captured.err == "" + + +class TestEnvLoaderSanitization: + """Tests for _sanitize_loaded_credentials in env_loader.""" + + def test_strips_non_ascii_from_api_key(self, monkeypatch): + from hermes_cli.env_loader import _sanitize_loaded_credentials + + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-proj-abcʋdef") + _sanitize_loaded_credentials() + assert os.environ["OPENROUTER_API_KEY"] == "sk-proj-abcdef" + + def test_strips_non_ascii_from_token(self, monkeypatch): + from hermes_cli.env_loader import _sanitize_loaded_credentials + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "tokénvalue") + _sanitize_loaded_credentials() + assert os.environ["DISCORD_BOT_TOKEN"] == "toknvalue" + + def test_ignores_non_credential_vars(self, monkeypatch): + from hermes_cli.env_loader import _sanitize_loaded_credentials + + monkeypatch.setenv("MY_UNICODE_VAR", "héllo wörld") + _sanitize_loaded_credentials() + # Not a credential suffix — should be left alone + assert os.environ["MY_UNICODE_VAR"] == "héllo wörld" + + def test_ascii_credentials_untouched(self, monkeypatch): + from hermes_cli.env_loader import _sanitize_loaded_credentials + + monkeypatch.setenv("OPENAI_API_KEY", "sk-proj-allascii123") + _sanitize_loaded_credentials() + assert os.environ["OPENAI_API_KEY"] == "sk-proj-allascii123" diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index fc175696e..ef4f3f339 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -142,6 +142,33 @@ class TestSurrogateVsAsciiSanitization: assert _sanitize_messages_surrogates(messages) is False +class TestApiKeyNonAsciiSanitization: + """Tests for API key sanitization in the UnicodeEncodeError recovery. + + Covers the root cause of issue #6843: a non-ASCII character (ʋ U+028B) + in the API key causes httpx to fail when encoding the Authorization + header as ASCII. The recovery block must strip non-ASCII from the key. + """ + + def test_strip_non_ascii_from_api_key(self): + """_strip_non_ascii removes ʋ from an API key string.""" + key = "sk-proj-abc" + "ʋ" + "def" + assert _strip_non_ascii(key) == "sk-proj-abcdef" + + def test_api_key_at_position_153(self): + """Reproduce the exact error: ʋ at position 153 in 'Bearer '.""" + key = "sk-proj-" + "a" * 138 + "ʋ" + "bcd" + auth_value = f"Bearer {key}" + # This is what httpx does — and it fails: + with pytest.raises(UnicodeEncodeError) as exc_info: + auth_value.encode("ascii") + assert exc_info.value.start == 153 + # After sanitization, it should work: + sanitized_key = _strip_non_ascii(key) + sanitized_auth = f"Bearer {sanitized_key}" + sanitized_auth.encode("ascii") # should not raise + + class TestSanitizeToolsNonAscii: """Tests for _sanitize_tools_non_ascii.""" From 99d859ce4ab116f7f6dc5dca57a08dda4aa7bd1a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 22:30:18 -0500 Subject: [PATCH 130/849] 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 d696cdef8..80b5b088d 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 b992ada06..5b406f1f5 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 04c276797..0b33e6e33 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 e6e10ec06..4776f0830 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 e9687ce7c..79edcce28 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 000000000..335e58d82 --- /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 000000000..8c3158017 --- /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 000000000..9f5df4ca9 --- /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 000000000..cdd9347fb --- /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 000000000..350687d74 --- /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 000000000..c4611f9dc --- /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 000000000..de4adad62 --- /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 000000000..501db36c9 --- /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 000000000..7e8b31753 --- /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 000000000..1db4594b9 --- /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 000000000..a6a611bc6 --- /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 053c62c59..000000000 --- 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 000000000..bb5769f3a --- /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 000000000..be33502ee --- /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 000000000..e3b646edd --- /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 b4c8e11ad..dbcfeb607 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 c7ae29e24..7688e6148 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 005d8cc4c..04f42ec16 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 ca52ec91c..3ab4be96b 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 50125d3b5..87adb2eb5 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 ba1880ed3..b17eff3ee 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 3ae7ada19..3ecb989ba 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 d6c09ab94a54a7aafd6bfbeebac4b9d965152d17 Mon Sep 17 00:00:00 2001 From: simon-marcus Date: Tue, 14 Apr 2026 09:48:49 -0400 Subject: [PATCH 131/849] feat(api-server): stream /v1/responses SSE tool events --- gateway/platforms/api_server.py | 486 ++++++++++++++++++ tests/gateway/test_api_server.py | 125 +++++ .../docs/user-guide/features/api-server.md | 6 +- .../docs/user-guide/messaging/open-webui.md | 4 +- 4 files changed, 617 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 2077c9c85..60b03a2c6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -515,6 +515,8 @@ class APIServerAdapter(BasePlatformAdapter): session_id: Optional[str] = None, stream_delta_callback=None, tool_progress_callback=None, + tool_start_callback=None, + tool_complete_callback=None, ) -> Any: """ Create an AIAgent instance using the gateway's runtime config. @@ -553,6 +555,8 @@ class APIServerAdapter(BasePlatformAdapter): platform="api_server", stream_delta_callback=stream_delta_callback, tool_progress_callback=tool_progress_callback, + tool_start_callback=tool_start_callback, + tool_complete_callback=tool_complete_callback, session_db=self._ensure_session_db(), fallback_model=fallback_model, ) @@ -965,6 +969,410 @@ class APIServerAdapter(BasePlatformAdapter): return response + async def _write_sse_responses( + self, + request: "web.Request", + response_id: str, + model: str, + created_at: int, + stream_q, + agent_task, + agent_ref, + conversation_history: List[Dict[str, str]], + user_message: str, + instructions: Optional[str], + conversation: Optional[str], + store: bool, + session_id: str, + ) -> "web.StreamResponse": + """Write an SSE stream for POST /v1/responses (OpenAI Responses API). + + Emits spec-compliant event types as the agent runs: + + - ``response.created`` — initial envelope (status=in_progress) + - ``response.output_text.delta`` / ``response.output_text.done`` — + streamed assistant text + - ``response.output_item.added`` / ``response.output_item.done`` + with ``item.type == "function_call"`` — when the agent invokes a + tool (both events fire; the ``done`` event carries the finalized + ``arguments`` string) + - ``response.output_item.added`` with + ``item.type == "function_call_output"`` — tool result with + ``{call_id, output, status}`` + - ``response.completed`` — terminal event carrying the full + response object with all output items + usage (same payload + shape as the non-streaming path for parity) + - ``response.failed`` — terminal event on agent error + + If the client disconnects mid-stream, ``agent.interrupt()`` is + called so the agent stops issuing upstream LLM calls, then the + asyncio task is cancelled. When ``store=True`` the full response + is persisted to the ResponseStore in a ``finally`` block so GET + /v1/responses/{id} and ``previous_response_id`` chaining work the + same as the batch path. + """ + import queue as _q + + sse_headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } + origin = request.headers.get("Origin", "") + cors = self._cors_headers_for_origin(origin) if origin else None + if cors: + sse_headers.update(cors) + if session_id: + sse_headers["X-Hermes-Session-Id"] = session_id + response = web.StreamResponse(status=200, headers=sse_headers) + await response.prepare(request) + + # State accumulated during the stream + final_text_parts: List[str] = [] + # Track open function_call items by name so we can emit a matching + # ``done`` event when the tool completes. Order preserved. + pending_tool_calls: List[Dict[str, Any]] = [] + # Output items we've emitted so far (used to build the terminal + # response.completed payload). Kept in the order they appeared. + emitted_items: List[Dict[str, Any]] = [] + # Monotonic counter for output_index (spec requires it). + output_index = 0 + # Monotonic counter for call_id generation if the agent doesn't + # provide one (it doesn't, from tool_progress_callback). + call_counter = 0 + # Track the assistant message item id + content index for text + # delta events — the spec ties deltas to a specific item. + message_item_id = f"msg_{uuid.uuid4().hex[:24]}" + message_output_index: Optional[int] = None + message_opened = False + + async def _write_event(event_type: str, data: Dict[str, Any]) -> None: + payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + await response.write(payload.encode()) + + def _envelope(status: str) -> Dict[str, Any]: + env: Dict[str, Any] = { + "id": response_id, + "object": "response", + "status": status, + "created_at": created_at, + "model": model, + } + return env + + final_response_text = "" + agent_error: Optional[str] = None + usage: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + + try: + # response.created — initial envelope, status=in_progress + created_env = _envelope("in_progress") + created_env["output"] = [] + await _write_event("response.created", { + "type": "response.created", + "response": created_env, + }) + last_activity = time.monotonic() + + async def _open_message_item() -> None: + """Emit response.output_item.added for the assistant message + the first time any text delta arrives.""" + nonlocal message_opened, message_output_index, output_index + if message_opened: + return + message_opened = True + message_output_index = output_index + output_index += 1 + item = { + "id": message_item_id, + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + } + await _write_event("response.output_item.added", { + "type": "response.output_item.added", + "output_index": message_output_index, + "item": item, + }) + + async def _emit_text_delta(delta_text: str) -> None: + await _open_message_item() + final_text_parts.append(delta_text) + await _write_event("response.output_text.delta", { + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_output_index, + "content_index": 0, + "delta": delta_text, + }) + + async def _emit_tool_started(payload: Dict[str, Any]) -> str: + """Emit response.output_item.added for a function_call. + + Returns the call_id so the matching completion event can + reference it. Prefer the real ``tool_call_id`` from the + agent when available; fall back to a generated call id for + safety in tests or older code paths. + """ + nonlocal output_index, call_counter + call_counter += 1 + call_id = payload.get("tool_call_id") or f"call_{response_id[5:]}_{call_counter}" + args = payload.get("arguments", {}) + if isinstance(args, dict): + arguments_str = json.dumps(args) + else: + arguments_str = str(args) + item = { + "id": f"fc_{uuid.uuid4().hex[:24]}", + "type": "function_call", + "status": "in_progress", + "name": payload.get("name", ""), + "call_id": call_id, + "arguments": arguments_str, + } + idx = output_index + output_index += 1 + pending_tool_calls.append({ + "call_id": call_id, + "name": payload.get("name", ""), + "arguments": arguments_str, + "item_id": item["id"], + "output_index": idx, + }) + emitted_items.append({ + "type": "function_call", + "name": payload.get("name", ""), + "arguments": arguments_str, + "call_id": call_id, + }) + await _write_event("response.output_item.added", { + "type": "response.output_item.added", + "output_index": idx, + "item": item, + }) + return call_id + + async def _emit_tool_completed(payload: Dict[str, Any]) -> None: + """Emit response.output_item.done (function_call) followed + by response.output_item.added (function_call_output).""" + nonlocal output_index + call_id = payload.get("tool_call_id") + result = payload.get("result", "") + pending = None + if call_id: + for i, p in enumerate(pending_tool_calls): + if p["call_id"] == call_id: + pending = pending_tool_calls.pop(i) + break + if pending is None: + # Completion without a matching start — skip to avoid + # emitting orphaned done events. + return + + # function_call done + done_item = { + "id": pending["item_id"], + "type": "function_call", + "status": "completed", + "name": pending["name"], + "call_id": pending["call_id"], + "arguments": pending["arguments"], + } + await _write_event("response.output_item.done", { + "type": "response.output_item.done", + "output_index": pending["output_index"], + "item": done_item, + }) + + # function_call_output added (result) + result_str = result if isinstance(result, str) else json.dumps(result) + output_item = { + "id": f"fco_{uuid.uuid4().hex[:24]}", + "type": "function_call_output", + "call_id": pending["call_id"], + "output": result_str, + "status": "completed", + } + idx = output_index + output_index += 1 + emitted_items.append({ + "type": "function_call_output", + "call_id": pending["call_id"], + "output": result_str, + }) + await _write_event("response.output_item.added", { + "type": "response.output_item.added", + "output_index": idx, + "item": output_item, + }) + + # Main drain loop — thread-safe queue fed by agent callbacks. + async def _dispatch(it) -> None: + """Route a queue item to the correct SSE emitter. + + Plain strings are text deltas. Tagged tuples with + ``__tool_started__`` / ``__tool_completed__`` prefixes + are tool lifecycle events. + """ + if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str): + tag, payload = it + if tag == "__tool_started__": + await _emit_tool_started(payload) + elif tag == "__tool_completed__": + await _emit_tool_completed(payload) + # Unknown tags are silently ignored (forward-compat). + elif isinstance(it, str): + await _emit_text_delta(it) + # Other types (non-string, non-tuple) are silently dropped. + + loop = asyncio.get_event_loop() + while True: + try: + item = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5)) + except _q.Empty: + if agent_task.done(): + # Drain remaining + while True: + try: + item = stream_q.get_nowait() + if item is None: + break + await _dispatch(item) + last_activity = time.monotonic() + except _q.Empty: + break + break + if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS: + await response.write(b": keepalive\n\n") + last_activity = time.monotonic() + continue + + if item is None: # EOS sentinel + break + + await _dispatch(item) + last_activity = time.monotonic() + + # Pick up agent result + usage from the completed task + try: + result, agent_usage = await agent_task + usage = agent_usage or usage + # If the agent produced a final_response but no text + # deltas were streamed (e.g. some providers only emit + # the full response at the end), emit a single fallback + # delta so Responses clients still receive a live text part. + agent_final = result.get("final_response", "") if isinstance(result, dict) else "" + if agent_final and not final_text_parts: + await _emit_text_delta(agent_final) + if agent_final and not final_response_text: + final_response_text = agent_final + if isinstance(result, dict) and result.get("error") and not final_response_text: + agent_error = result["error"] + except Exception as e: # noqa: BLE001 + logger.error("Error running agent for streaming responses: %s", e, exc_info=True) + agent_error = str(e) + + # Close the message item if it was opened + final_response_text = "".join(final_text_parts) or final_response_text + if message_opened: + await _write_event("response.output_text.done", { + "type": "response.output_text.done", + "item_id": message_item_id, + "output_index": message_output_index, + "content_index": 0, + "text": final_response_text, + }) + msg_done_item = { + "id": message_item_id, + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + {"type": "output_text", "text": final_response_text} + ], + } + await _write_event("response.output_item.done", { + "type": "response.output_item.done", + "output_index": message_output_index, + "item": msg_done_item, + }) + + # Always append a final message item in the completed + # response envelope so clients that only parse the terminal + # payload still see the assistant text. This mirrors the + # shape produced by _extract_output_items in the batch path. + final_items: List[Dict[str, Any]] = list(emitted_items) + final_items.append({ + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": final_response_text or (agent_error or "")} + ], + }) + + if agent_error: + failed_env = _envelope("failed") + failed_env["output"] = final_items + failed_env["error"] = {"message": agent_error, "type": "server_error"} + failed_env["usage"] = { + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } + await _write_event("response.failed", { + "type": "response.failed", + "response": failed_env, + }) + else: + completed_env = _envelope("completed") + completed_env["output"] = final_items + completed_env["usage"] = { + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } + await _write_event("response.completed", { + "type": "response.completed", + "response": completed_env, + }) + + # Persist for future chaining / GET retrieval, mirroring + # the batch path behavior. + if store: + full_history = list(conversation_history) + full_history.append({"role": "user", "content": user_message}) + if isinstance(result, dict) and result.get("messages"): + full_history.extend(result["messages"]) + else: + full_history.append({"role": "assistant", "content": final_response_text}) + self._response_store.put(response_id, { + "response": completed_env, + "conversation_history": full_history, + "instructions": instructions, + }) + if conversation: + self._response_store.set_conversation(conversation, response_id) + + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError): + # Client disconnected — interrupt the agent so it stops + # making upstream LLM calls, then cancel the task. + agent = agent_ref[0] if agent_ref else None + if agent is not None: + try: + agent.interrupt("SSE client disconnected") + except Exception: + pass + if not agent_task.done(): + agent_task.cancel() + try: + await agent_task + except (asyncio.CancelledError, Exception): + pass + logger.info("SSE client disconnected; interrupted agent task %s", response_id) + + return response + async def _handle_responses(self, request: "web.Request") -> "web.Response": """POST /v1/responses — OpenAI Responses API format.""" auth_err = self._check_auth(request) @@ -1060,6 +1468,80 @@ class APIServerAdapter(BasePlatformAdapter): # Run the agent (with Idempotency-Key support) session_id = str(uuid.uuid4()) + stream = bool(body.get("stream", False)) + if stream: + # Streaming branch — emit OpenAI Responses SSE events as the + # agent runs so frontends can render text deltas and tool + # calls in real time. See _write_sse_responses for details. + import queue as _q + _stream_q: _q.Queue = _q.Queue() + + def _on_delta(delta): + # None from the agent is a CLI box-close signal, not EOS. + # Forwarding would kill the SSE stream prematurely; the + # SSE writer detects completion via agent_task.done(). + if delta is not None: + _stream_q.put(delta) + + def _on_tool_progress(event_type, name, preview, args, **kwargs): + """Queue non-start tool progress events if needed in future. + + The structured Responses stream uses ``tool_start_callback`` + and ``tool_complete_callback`` for exact call-id correlation, + so progress events are currently ignored here. + """ + return + + def _on_tool_start(tool_call_id, function_name, function_args): + """Queue a started tool for live function_call streaming.""" + _stream_q.put(("__tool_started__", { + "tool_call_id": tool_call_id, + "name": function_name, + "arguments": function_args or {}, + })) + + def _on_tool_complete(tool_call_id, function_name, function_args, function_result): + """Queue a completed tool result for live function_call_output streaming.""" + _stream_q.put(("__tool_completed__", { + "tool_call_id": tool_call_id, + "name": function_name, + "arguments": function_args or {}, + "result": function_result, + })) + + agent_ref = [None] + agent_task = asyncio.ensure_future(self._run_agent( + user_message=user_message, + conversation_history=conversation_history, + ephemeral_system_prompt=instructions, + session_id=session_id, + stream_delta_callback=_on_delta, + tool_progress_callback=_on_tool_progress, + tool_start_callback=_on_tool_start, + tool_complete_callback=_on_tool_complete, + agent_ref=agent_ref, + )) + + response_id = f"resp_{uuid.uuid4().hex[:28]}" + model_name = body.get("model", self._model_name) + created_at = int(time.time()) + + return await self._write_sse_responses( + request=request, + response_id=response_id, + model=model_name, + created_at=created_at, + stream_q=_stream_q, + agent_task=agent_task, + agent_ref=agent_ref, + conversation_history=conversation_history, + user_message=user_message, + instructions=instructions, + conversation=conversation, + store=store, + session_id=session_id, + ) + async def _compute_response(): return await self._run_agent( user_message=user_message, @@ -1486,6 +1968,8 @@ class APIServerAdapter(BasePlatformAdapter): session_id: Optional[str] = None, stream_delta_callback=None, tool_progress_callback=None, + tool_start_callback=None, + tool_complete_callback=None, agent_ref: Optional[list] = None, ) -> tuple: """ @@ -1507,6 +1991,8 @@ class APIServerAdapter(BasePlatformAdapter): session_id=session_id, stream_delta_callback=stream_delta_callback, tool_progress_callback=tool_progress_callback, + tool_start_callback=tool_start_callback, + tool_complete_callback=tool_complete_callback, ) if agent_ref is not None: agent_ref[0] = agent diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index be1fc63bf..28df94e0f 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1115,6 +1115,131 @@ class TestResponsesEndpoint: assert resp.status == 400 +class TestResponsesStreaming: + @pytest.mark.asyncio + async def test_stream_true_returns_responses_sse(self, adapter): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): + cb = kwargs.get("stream_delta_callback") + if cb: + cb("Hello") + cb(" world") + return ( + {"final_response": "Hello world", "messages": [], "api_calls": 1}, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): + resp = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "hi", "stream": True}, + ) + assert resp.status == 200 + assert "text/event-stream" in resp.headers.get("Content-Type", "") + body = await resp.text() + assert "event: response.created" in body + assert "event: response.output_text.delta" in body + assert "event: response.output_text.done" in body + assert "event: response.completed" in body + assert "Hello" in body + assert " world" in body + + @pytest.mark.asyncio + async def test_stream_emits_function_call_and_output_items(self, adapter): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): + start_cb = kwargs.get("tool_start_callback") + complete_cb = kwargs.get("tool_complete_callback") + text_cb = kwargs.get("stream_delta_callback") + if start_cb: + start_cb("call_123", "read_file", {"path": "/tmp/test.txt"}) + if complete_cb: + complete_cb("call_123", "read_file", {"path": "/tmp/test.txt"}, '{"content":"hello"}') + if text_cb: + text_cb("Done.") + return ( + { + "final_response": "Done.", + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_123", + "function": { + "name": "read_file", + "arguments": '{"path":"/tmp/test.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_123", + "content": '{"content":"hello"}', + }, + ], + "api_calls": 1, + }, + {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + ) + + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): + resp = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "read the file", "stream": True}, + ) + assert resp.status == 200 + body = await resp.text() + assert "event: response.output_item.added" in body + assert "event: response.output_item.done" in body + assert '"type": "function_call"' in body + assert '"type": "function_call_output"' in body + assert '"call_id": "call_123"' in body + assert '"name": "read_file"' in body + assert '"output": "{\\"content\\":\\"hello\\"}"' in body + + @pytest.mark.asyncio + async def test_streamed_response_is_stored_for_get(self, adapter): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): + cb = kwargs.get("stream_delta_callback") + if cb: + cb("Stored response") + return ( + {"final_response": "Stored response", "messages": [], "api_calls": 1}, + {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + ) + + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): + resp = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "store this", "stream": True}, + ) + body = await resp.text() + response_id = None + for line in body.splitlines(): + if line.startswith("data: "): + try: + payload = json.loads(line[len("data: "):]) + except json.JSONDecodeError: + continue + if payload.get("type") == "response.completed": + response_id = payload["response"]["id"] + break + assert response_id + + get_resp = await cli.get(f"/v1/responses/{response_id}") + assert get_resp.status == 200 + data = await get_resp.json() + assert data["id"] == response_id + assert data["status"] == "completed" + assert data["output"][-1]["content"][0]["text"] == "Stored response" + + # --------------------------------------------------------------------------- # Auth on endpoints # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index efb254a00..52ed8e893 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -83,9 +83,11 @@ Standard OpenAI Chat Completions format. Stateless — the full conversation is } ``` -**Streaming** (`"stream": true`): Returns Server-Sent Events (SSE) with token-by-token response chunks. When streaming is enabled in config, tokens are emitted live as the LLM generates them. When disabled, the full response is sent as a single SSE chunk. +**Streaming** (`"stream": true`): Returns Server-Sent Events (SSE) with token-by-token response chunks. For **Chat Completions**, the stream uses standard `chat.completion.chunk` events plus Hermes' custom `hermes.tool.progress` event for tool-start UX. For **Responses**, the stream uses OpenAI Responses event types such as `response.created`, `response.output_text.delta`, `response.output_item.added`, `response.output_item.done`, and `response.completed`. -**Tool progress in streams**: When the agent calls tools during a streaming request, brief progress indicators are injected into the content stream as the tools start executing (e.g. `` `💻 pwd` ``, `` `🔍 Python docs` ``). These appear as inline markdown before the agent's response text, giving frontends like Open WebUI real-time visibility into tool execution. +**Tool progress in streams**: +- **Chat Completions**: Hermes emits `event: hermes.tool.progress` for tool-start visibility without polluting persisted assistant text. +- **Responses**: Hermes emits spec-native `function_call` and `function_call_output` output items during the SSE stream, so clients can render structured tool UI in real time. ### POST /v1/responses diff --git a/website/docs/user-guide/messaging/open-webui.md b/website/docs/user-guide/messaging/open-webui.md index 71860d367..b26d23edd 100644 --- a/website/docs/user-guide/messaging/open-webui.md +++ b/website/docs/user-guide/messaging/open-webui.md @@ -134,10 +134,10 @@ To use the Responses API mode: 3. Change **API Type** from "Chat Completions" to **"Responses (Experimental)"** 4. Save -With the Responses API, Open WebUI sends requests in the Responses format (`input` array + `instructions`), and Hermes Agent can preserve full tool call history across turns via `previous_response_id`. +With the Responses API, Open WebUI sends requests in the Responses format (`input` array + `instructions`), and Hermes Agent can preserve full tool call history across turns via `previous_response_id`. When `stream: true`, Hermes also streams spec-native `function_call` and `function_call_output` items, which enables custom structured tool-call UI in clients that render Responses events. :::note -Open WebUI currently manages conversation history client-side even in Responses mode — it sends the full message history in each request rather than using `previous_response_id`. The Responses API mode is mainly useful for future compatibility as frontends evolve. +Open WebUI currently manages conversation history client-side even in Responses mode — it sends the full message history in each request rather than using `previous_response_id`. The main advantage of Responses mode today is the structured event stream: text deltas, `function_call`, and `function_call_output` items arrive as OpenAI Responses SSE events instead of Chat Completions chunks. ::: ## How It Works From 302554b1588370dde48ecd78b9b64e2f6fd8c1fe Mon Sep 17 00:00:00 2001 From: simon-marcus Date: Tue, 14 Apr 2026 13:14:26 -0400 Subject: [PATCH 132/849] fix(api-server): format responses tool outputs for open webui --- gateway/platforms/api_server.py | 27 ++++++++++++++++++++++++--- tests/gateway/test_api_server.py | 7 +++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 60b03a2c6..62196973d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1040,6 +1040,10 @@ class APIServerAdapter(BasePlatformAdapter): # Monotonic counter for call_id generation if the agent doesn't # provide one (it doesn't, from tool_progress_callback). call_counter = 0 + # Canonical Responses SSE events include a monotonically increasing + # sequence_number. Add it server-side for every emitted event so + # clients that validate the OpenAI event schema can parse our stream. + sequence_number = 0 # Track the assistant message item id + content index for text # delta events — the spec ties deltas to a specific item. message_item_id = f"msg_{uuid.uuid4().hex[:24]}" @@ -1047,6 +1051,10 @@ class APIServerAdapter(BasePlatformAdapter): message_opened = False async def _write_event(event_type: str, data: Dict[str, Any]) -> None: + nonlocal sequence_number + if "sequence_number" not in data: + data["sequence_number"] = sequence_number + sequence_number += 1 payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n" await response.write(payload.encode()) @@ -1105,6 +1113,7 @@ class APIServerAdapter(BasePlatformAdapter): "output_index": message_output_index, "content_index": 0, "delta": delta_text, + "logprobs": [], }) async def _emit_tool_started(payload: Dict[str, Any]) -> str: @@ -1187,11 +1196,12 @@ class APIServerAdapter(BasePlatformAdapter): # function_call_output added (result) result_str = result if isinstance(result, str) else json.dumps(result) + output_parts = [{"type": "input_text", "text": result_str}] output_item = { "id": f"fco_{uuid.uuid4().hex[:24]}", "type": "function_call_output", "call_id": pending["call_id"], - "output": result_str, + "output": output_parts, "status": "completed", } idx = output_index @@ -1199,13 +1209,18 @@ class APIServerAdapter(BasePlatformAdapter): emitted_items.append({ "type": "function_call_output", "call_id": pending["call_id"], - "output": result_str, + "output": output_parts, }) await _write_event("response.output_item.added", { "type": "response.output_item.added", "output_index": idx, "item": output_item, }) + await _write_event("response.output_item.done", { + "type": "response.output_item.done", + "output_index": idx, + "item": output_item, + }) # Main drain loop — thread-safe queue fed by agent callbacks. async def _dispatch(it) -> None: @@ -1282,6 +1297,7 @@ class APIServerAdapter(BasePlatformAdapter): "output_index": message_output_index, "content_index": 0, "text": final_response_text, + "logprobs": [], }) msg_done_item = { "id": message_item_id, @@ -1933,10 +1949,15 @@ class APIServerAdapter(BasePlatformAdapter): "call_id": tc.get("id", ""), }) elif role == "tool": + output_content = msg.get("content", "") + if isinstance(output_content, list): + output = output_content + else: + output = [{"type": "input_text", "text": str(output_content)}] items.append({ "type": "function_call_output", "call_id": msg.get("tool_call_id", ""), - "output": msg.get("content", ""), + "output": output, }) # Final assistant message diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 28df94e0f..32346fc83 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1142,6 +1142,8 @@ class TestResponsesStreaming: assert "event: response.output_text.delta" in body assert "event: response.output_text.done" in body assert "event: response.completed" in body + assert '"sequence_number":' in body + assert '"logprobs": []' in body assert "Hello" in body assert " world" in body @@ -1195,11 +1197,12 @@ class TestResponsesStreaming: body = await resp.text() assert "event: response.output_item.added" in body assert "event: response.output_item.done" in body + assert body.count("event: response.output_item.done") >= 2 assert '"type": "function_call"' in body assert '"type": "function_call_output"' in body assert '"call_id": "call_123"' in body assert '"name": "read_file"' in body - assert '"output": "{\\"content\\":\\"hello\\"}"' in body + assert '"output": [{"type": "input_text", "text": "{\\"content\\":\\"hello\\"}"}]' in body @pytest.mark.asyncio async def test_streamed_response_is_stored_for_get(self, adapter): @@ -1544,7 +1547,7 @@ class TestToolCallsInOutput: assert output[0]["call_id"] == "call_abc123" assert output[1]["type"] == "function_call_output" assert output[1]["call_id"] == "call_abc123" - assert output[1]["output"] == "42" + assert output[1]["output"] == [{"type": "input_text", "text": "42"}] assert output[2]["type"] == "message" assert output[2]["content"][0]["text"] == "The result is 42." From cf1d71882304c5c37f9194c389ac2159c23a1c4a Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 20:47:06 -0700 Subject: [PATCH 133/849] fix: keep batch-path function_call_output.output as string per OpenAI spec The streaming path emits output as content-part arrays for Open WebUI compatibility, but the batch (non-streaming) Responses API path must return output as a plain string per the OpenAI Responses API spec. Reverts the _extract_output_items change from the cherry-picked commits while preserving the streaming path's array format. --- gateway/platforms/api_server.py | 7 +------ tests/gateway/test_api_server.py | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 62196973d..32c56d1fb 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1949,15 +1949,10 @@ class APIServerAdapter(BasePlatformAdapter): "call_id": tc.get("id", ""), }) elif role == "tool": - output_content = msg.get("content", "") - if isinstance(output_content, list): - output = output_content - else: - output = [{"type": "input_text", "text": str(output_content)}] items.append({ "type": "function_call_output", "call_id": msg.get("tool_call_id", ""), - "output": output, + "output": msg.get("content", ""), }) # Final assistant message diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 32346fc83..8e3e066b8 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1547,7 +1547,7 @@ class TestToolCallsInOutput: assert output[0]["call_id"] == "call_abc123" assert output[1]["type"] == "function_call_output" assert output[1]["call_id"] == "call_abc123" - assert output[1]["output"] == [{"type": "input_text", "text": "42"}] + assert output[1]["output"] == "42" assert output[2]["type"] == "message" assert output[2]["content"][0]["text"] == "The result is 42." From 31d06206630669a27ca0cc4f0261a414d3e8af1e Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 20:47:57 -0700 Subject: [PATCH 134/849] chore: add simon-marcus to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index fb7924640..74f0fc420 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -117,6 +117,7 @@ AUTHOR_MAP = { "m@statecraft.systems": "mbierling", "balyan.sid@gmail.com": "balyansid", "oluwadareab12@gmail.com": "bennytimz", + "simon@simonmarcus.org": "simon-marcus", # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply # crossref, and GH contributor list matching (April 2026 audit) ── "1115117931@qq.com": "aaronagent", From 82f364ffd1d7f85cb4faa0fbfd2095ada0a78f84 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:52:18 -0700 Subject: [PATCH 135/849] feat: add --all flag to gateway start and restart commands (#10043) - gateway start --all: kills all stale gateway processes across all profiles before starting the current profile's service - gateway restart --all: stops all gateway processes across all profiles, then starts the current profile's service fresh - gateway stop --all: already existed, unchanged The --all flag was only available on 'stop' but not on 'start' or 'restart', causing 'unrecognized arguments' errors for users. --- hermes_cli/gateway.py | 41 +++++++++++++++++++++++++++++++++++++++++ hermes_cli/main.py | 2 ++ 2 files changed, 43 insertions(+) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 4b13bc70f..58d9f92ed 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2919,6 +2919,15 @@ def gateway_command(args): elif subcmd == "start": system = getattr(args, 'system', False) + start_all = getattr(args, 'all', False) + + if start_all: + # Kill all stale gateway processes across all profiles before starting + killed = kill_gateway_processes(all_profiles=True) + if killed: + print(f"✓ Killed {killed} stale gateway process(es) across all profiles") + _wait_for_gateway_exit(timeout=10.0, force_after=5.0) + if is_termux(): print("Gateway service start is not supported on Termux because there is no system service manager.") print("Run manually: hermes gateway") @@ -3004,7 +3013,39 @@ def gateway_command(args): # Try service first, fall back to killing and restarting service_available = False system = getattr(args, 'system', False) + restart_all = getattr(args, 'all', False) service_configured = False + + if restart_all: + # --all: stop every gateway process across all profiles, then start fresh + service_stopped = False + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + try: + systemd_stop(system=system) + service_stopped = True + except subprocess.CalledProcessError: + pass + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_stop() + service_stopped = True + except subprocess.CalledProcessError: + pass + killed = kill_gateway_processes(all_profiles=True) + total = killed + (1 if service_stopped else 0) + if total: + print(f"✓ Stopped {total} gateway process(es) across all profiles") + _wait_for_gateway_exit(timeout=10.0, force_after=5.0) + + # Start the current profile's service fresh + print("Starting gateway...") + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + systemd_start(system=system) + elif is_macos() and get_launchd_plist_path().exists(): + launchd_start() + else: + run_gateway(verbose=0) + return if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): service_configured = True diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c73344be4..017280184 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4749,6 +4749,7 @@ For more help on a command: # 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 stop gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") @@ -4758,6 +4759,7 @@ For more help on a command: # 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 status gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") From 92385679b64ef0f34aca2ec1b1031c4e639622bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E6=B3=A5=E8=B1=86?= <1243352777@qq.com> Date: Tue, 14 Apr 2026 16:58:37 +0800 Subject: [PATCH 136/849] fix: reset retry counters after compression and stop poisoning conversation history Three bugfixes in the agent loop: 1. Reset retry counters after context compression. Without this, pre-compression retry counts carry over, causing the model to hit empty-response recovery immediately after a compression- induced context loss, wasting API calls on a now-valid context. 2. Unmute output in the final-response (no-tool-call) branch. _mute_post_response could be left True from a prior housekeeping turn, silently suppressing empty-response warnings and recovery status that the user should see. 3. Stop injecting 'Calling the X tools...' into assistant message content when falling back to prior-turn content. This mutated conversation history with synthetic text that the model never produced, poisoning subsequent turns. --- run_agent.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/run_agent.py b/run_agent.py index 0d6be24d0..0814a8b49 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8012,6 +8012,15 @@ class AIAgent: # skipping them because conversation_history is still the # pre-compression length. conversation_history = None + # Fix: reset retry counters after compression so the model + # gets a fresh budget on the compressed context. Without + # this, pre-compression retries carry over and the model + # hits "(empty)" immediately after compression-induced + # context loss. + self._empty_content_retries = 0 + self._thinking_prefill_retries = 0 + self._last_content_with_tools = None + self._mute_post_response = False # Re-estimate after compression _preflight_tokens = estimate_request_tokens_rough( messages, @@ -10202,6 +10211,13 @@ class AIAgent: # No tool calls - this is the final response final_response = assistant_message.content or "" + # Fix: unmute output when entering the no-tool-call branch + # so the user can see empty-response warnings and recovery + # status messages. _mute_post_response was set during a + # prior housekeeping tool turn and should not silence the + # final response path. + self._mute_post_response = False + # Check if response only has think block with no actual content after it if not self._has_content_after_think_block(final_response): # ── Partial stream recovery ───────────────────── @@ -10239,16 +10255,10 @@ class AIAgent: self._emit_status("↻ Empty response after tool calls — using earlier content as final answer") self._last_content_with_tools = None self._empty_content_retries = 0 - for i in range(len(messages) - 1, -1, -1): - msg = messages[i] - if msg.get("role") == "assistant" and msg.get("tool_calls"): - tool_names = [] - for tc in msg["tool_calls"]: - if not tc or not isinstance(tc, dict): continue - fn = tc.get("function", {}) - tool_names.append(fn.get("name", "unknown")) - msg["content"] = f"Calling the {', '.join(tool_names)} tool{'s' if len(tool_names) > 1 else ''}..." - break + # Do NOT modify the assistant message content — the + # old code injected "Calling the X tools..." which + # poisoned the conversation history. Just use the + # fallback text as the final response and break. final_response = self._strip_think_blocks(fallback).strip() self._response_was_previewed = True break From 23b87c8ca82299ccdbcde30c6b53bbae84da93de Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 20:55:34 -0700 Subject: [PATCH 137/849] chore: add zons-zhaozhy to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 74f0fc420..046255627 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -118,6 +118,7 @@ AUTHOR_MAP = { "balyan.sid@gmail.com": "balyansid", "oluwadareab12@gmail.com": "bennytimz", "simon@simonmarcus.org": "simon-marcus", + "1243352777@qq.com": "zons-zhaozhy", # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply # crossref, and GH contributor list matching (April 2026 audit) ── "1115117931@qq.com": "aaronagent", From ca0ae56ccbb5a1308beece973b2c93c50075318e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:03:05 -0700 Subject: [PATCH 138/849] fix: add 402 billing error hint to gateway error handler (#5220) (#10057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: hermes gateway restart waits for service to come back up (#8260) Previously, systemd_restart() sent SIGUSR1 to the gateway, printed 'restart requested', and returned immediately. The gateway still needed to drain active agents, exit with code 75, wait for systemd's RestartSec=30, and start the new process. The user saw 'success' but the gateway was actually down for 30-60 seconds. Now the SIGUSR1 path blocks with progress feedback: Phase 1 — wait for old process to die: ⏳ User service draining active work... Polls os.kill(pid, 0) until ProcessLookupError (up to 90s) Phase 2 — wait for new process to become active: ⏳ Waiting for hermes-gateway to restart... Polls systemctl is-active + verifies new PID (up to 60s) Success: ✓ User service restarted (PID 12345) Timeout: ⚠ User service did not become active within 60s. Check status: hermes gateway status Check logs: journalctl --user -u hermes-gateway --since '2 min ago' The reload-or-restart fallback path (line 1189) already blocks because systemctl reload-or-restart is synchronous. Test plan: - Updated test to verify wait-for-restart behavior - All 118 gateway CLI tests pass * fix: add 402 billing error hint to gateway error handler (#5220) The gateway's exception handler for agent errors had specific hints for HTTP 401, 429, 529, 400, 500 — but not 402 (Payment Required / quota exhausted). Users hitting billing limits from custom proxy providers got a generic error with no guidance. Added: 'Your API balance or quota is exhausted. Check your provider dashboard.' The underlying billing classification (error_classifier.py) already correctly handles 402 as FailoverReason.billing with credential rotation and fallback. The original issue (#5220) where 402 killed the entire gateway was from an older version — on current main, 402 is excluded from the is_client_error abort path (line 9460) and goes through the proper retry/fallback/fail flow. Combined with PR #9875 (auto-recover from unexpected SIGTERM), even edge cases where the gateway dies are now survivable. --- gateway/run.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index d137d73c3..c59e9dff1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4020,6 +4020,8 @@ class GatewayRunner: _hist_len = len(history) if 'history' in locals() else 0 if status_code == 401: status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials." + elif status_code == 402: + status_hint = " Your API balance or quota is exhausted. Check your provider dashboard." elif status_code == 429: # Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit _err_body = getattr(e, "response", None) From 5cbb45d93e8e70a91c517c8b89f7b817a02e5842 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:06:32 -0700 Subject: [PATCH 139/849] fix: preserve session_id across previous_response_id chains in /v1/responses (#10059) The /v1/responses endpoint generated a new UUID session_id for every request, even when previous_response_id was provided. This caused each turn of a multi-turn conversation to appear as a separate session on the web dashboard, despite the conversation history being correctly chained. Fix: store session_id alongside the response in the ResponseStore, and reuse it when a subsequent request chains via previous_response_id. Applies to both the non-streaming /v1/responses path and the streaming SSE path. The /v1/runs endpoint also gains session continuity from stored responses (explicit body.session_id still takes priority). Adds test verifying session_id is preserved across chained requests. --- gateway/platforms/api_server.py | 13 +++++++--- tests/gateway/test_api_server.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 32c56d1fb..7f4c8e8d6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1366,6 +1366,7 @@ class APIServerAdapter(BasePlatformAdapter): "response": completed_env, "conversation_history": full_history, "instructions": instructions, + "session_id": session_id, }) if conversation: self._response_store.set_conversation(conversation, response_id) @@ -1459,11 +1460,13 @@ class APIServerAdapter(BasePlatformAdapter): if previous_response_id: logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") + stored_session_id = None if not conversation_history and previous_response_id: stored = self._response_store.get(previous_response_id) if stored is None: return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404) conversation_history = list(stored.get("conversation_history", [])) + stored_session_id = stored.get("session_id") # If no instructions provided, carry forward from previous if instructions is None: instructions = stored.get("instructions") @@ -1481,8 +1484,9 @@ class APIServerAdapter(BasePlatformAdapter): if body.get("truncation") == "auto" and len(conversation_history) > 100: conversation_history = conversation_history[-100:] - # Run the agent (with Idempotency-Key support) - session_id = str(uuid.uuid4()) + # Reuse session from previous_response_id chain so the dashboard + # groups the entire conversation under one session entry. + session_id = stored_session_id or str(uuid.uuid4()) stream = bool(body.get("stream", False)) if stream: @@ -1631,6 +1635,7 @@ class APIServerAdapter(BasePlatformAdapter): "response": response_data, "conversation_history": full_history, "instructions": instructions, + "session_id": session_id, }) # Update conversation mapping so the next request with the same # conversation name automatically chains to this response @@ -2145,10 +2150,12 @@ class APIServerAdapter(BasePlatformAdapter): if previous_response_id: logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") + stored_session_id = None if not conversation_history and previous_response_id: stored = self._response_store.get(previous_response_id) if stored: conversation_history = list(stored.get("conversation_history", [])) + stored_session_id = stored.get("session_id") if instructions is None: instructions = stored.get("instructions") @@ -2167,7 +2174,7 @@ class APIServerAdapter(BasePlatformAdapter): ) conversation_history.append({"role": msg["role"], "content": str(content)}) - session_id = body.get("session_id") or run_id + session_id = body.get("session_id") or stored_session_id or run_id ephemeral_system_prompt = instructions async def _run_and_close(): diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 8e3e066b8..d0cebacb8 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -1016,6 +1016,47 @@ class TestResponsesEndpoint: assert len(call_kwargs["conversation_history"]) > 0 assert call_kwargs["user_message"] == "Now add 1 more" + @pytest.mark.asyncio + async def test_previous_response_id_preserves_session(self, adapter): + """Chained responses via previous_response_id reuse the same session_id.""" + mock_result = { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + "api_calls": 1, + } + usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + # First request — establishes a session + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, usage) + resp1 = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "Hello"}, + ) + assert resp1.status == 200 + first_session_id = mock_run.call_args.kwargs["session_id"] + data1 = await resp1.json() + response_id = data1["id"] + + # Second request — chains from the first + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, usage) + resp2 = await cli.post( + "/v1/responses", + json={ + "model": "hermes-agent", + "input": "Follow up", + "previous_response_id": response_id, + }, + ) + assert resp2.status == 200 + second_session_id = mock_run.call_args.kwargs["session_id"] + + # Session must be the same across the chain + assert first_session_id == second_session_id + @pytest.mark.asyncio async def test_invalid_previous_response_id_returns_404(self, adapter): app = _create_app(adapter) From 2871ef18078ba2464d9afebeaf3e7ad67e4d4a5f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:07:37 -0700 Subject: [PATCH 140/849] docs: note session continuity for previous_response_id chains (#10060) --- website/docs/user-guide/features/api-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 52ed8e893..ebcb4523e 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -130,7 +130,7 @@ Chain responses to maintain full context (including tool calls) across turns: } ``` -The server reconstructs the full conversation from the stored response chain — all previous tool calls and results are preserved. +The server reconstructs the full conversation from the stored response chain — all previous tool calls and results are preserved. Chained requests also share the same session, so multi-turn conversations appear as a single entry in the dashboard and session history. #### Named conversations From 4b2a1a4337a0409d13146596a219b480e216471a Mon Sep 17 00:00:00 2001 From: Greer Guthrie <149740518+g-guthrie@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:02:25 -0500 Subject: [PATCH 141/849] fix(tools): auto-discover built-in tool modules --- model_tools.py | 42 +-------------------- tests/tools/test_registry.py | 72 +++++++++++++++++++++++++++++++++++- tools/registry.py | 45 ++++++++++++++++++++++ 3 files changed, 118 insertions(+), 41 deletions(-) diff --git a/model_tools.py b/model_tools.py index 1924b2516..801255b79 100644 --- a/model_tools.py +++ b/model_tools.py @@ -26,7 +26,7 @@ import logging import threading from typing import Dict, Any, List, Optional, Tuple -from tools.registry import registry +from tools.registry import discover_builtin_tools, registry from toolsets import resolve_toolset, validate_toolset logger = logging.getLogger(__name__) @@ -129,45 +129,7 @@ def _run_async(coro): # Tool Discovery (importing each module triggers its registry.register calls) # ============================================================================= -def _discover_tools(): - """Import all tool modules to trigger their registry.register() calls. - - Wrapped in a function so import errors in optional tools (e.g., fal_client - not installed) don't prevent the rest from loading. - """ - _modules = [ - "tools.web_tools", - "tools.terminal_tool", - "tools.file_tools", - "tools.vision_tools", - "tools.mixture_of_agents_tool", - "tools.image_generation_tool", - "tools.skills_tool", - "tools.skill_manager_tool", - "tools.browser_tool", - "tools.cronjob_tools", - "tools.rl_training_tool", - "tools.tts_tool", - "tools.todo_tool", - "tools.memory_tool", - "tools.session_search_tool", - "tools.clarify_tool", - "tools.code_execution_tool", - "tools.delegate_tool", - "tools.process_registry", - "tools.send_message_tool", - # "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin - "tools.homeassistant_tool", - ] - import importlib - for mod_name in _modules: - try: - importlib.import_module(mod_name) - except Exception as e: - logger.warning("Could not import tool module %s: %s", mod_name, e) - - -_discover_tools() +discover_builtin_tools() # MCP tool discovery (external MCP servers from config) try: diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 6b2756886..85246bd76 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -2,8 +2,10 @@ import json import threading +from pathlib import Path +from unittest.mock import patch -from tools.registry import ToolRegistry +from tools.registry import ToolRegistry, discover_builtin_tools def _dummy_handler(args, **kwargs): @@ -286,6 +288,74 @@ class TestCheckFnExceptionHandling: assert any(u["name"] == "crashes" for u in unavailable) +class TestBuiltinDiscovery: + def test_matches_previous_manual_builtin_tool_set(self): + expected = { + "tools.browser_tool", + "tools.clarify_tool", + "tools.code_execution_tool", + "tools.cronjob_tools", + "tools.delegate_tool", + "tools.file_tools", + "tools.homeassistant_tool", + "tools.image_generation_tool", + "tools.memory_tool", + "tools.mixture_of_agents_tool", + "tools.process_registry", + "tools.rl_training_tool", + "tools.send_message_tool", + "tools.session_search_tool", + "tools.skill_manager_tool", + "tools.skills_tool", + "tools.terminal_tool", + "tools.todo_tool", + "tools.tts_tool", + "tools.vision_tools", + "tools.web_tools", + } + + with patch("tools.registry.importlib.import_module"): + imported = discover_builtin_tools(Path(__file__).resolve().parents[2] / "tools") + + assert set(imported) == expected + + def test_imports_only_self_registering_modules(self, tmp_path): + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + (tools_dir / "__init__.py").write_text("", encoding="utf-8") + (tools_dir / "registry.py").write_text("", encoding="utf-8") + (tools_dir / "alpha.py").write_text( + "from tools.registry import registry\nregistry.register(name='alpha', toolset='x', schema={}, handler=lambda *_a, **_k: '{}')\n", + encoding="utf-8", + ) + (tools_dir / "beta.py").write_text("VALUE = 1\n", encoding="utf-8") + + with patch("tools.registry.importlib.import_module") as mock_import: + imported = discover_builtin_tools(tools_dir) + + assert imported == ["tools.alpha"] + mock_import.assert_called_once_with("tools.alpha") + + def test_skips_mcp_tool_even_if_it_registers(self, tmp_path): + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + (tools_dir / "__init__.py").write_text("", encoding="utf-8") + (tools_dir / "mcp_tool.py").write_text( + "from tools.registry import registry\nregistry.register(name='mcp_alpha', toolset='mcp-test', schema={}, handler=lambda *_a, **_k: '{}')\n", + encoding="utf-8", + ) + (tools_dir / "alpha.py").write_text( + "from tools.registry import registry\nregistry.register(name='alpha', toolset='x', schema={}, handler=lambda *_a, **_k: '{}')\n", + encoding="utf-8", + ) + + with patch("tools.registry.importlib.import_module") as mock_import: + imported = discover_builtin_tools(tools_dir) + + assert imported == ["tools.alpha"] + mock_import.assert_called_once_with("tools.alpha") + + class TestEmojiMetadata: """Verify per-tool emoji registration and lookup.""" diff --git a/tools/registry.py b/tools/registry.py index ebda77807..53939047b 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -14,14 +14,59 @@ Import chain (circular-import safe): run_agent.py, cli.py, batch_runner.py, etc. """ +import ast +import importlib import json import logging import threading +from pathlib import Path from typing import Callable, Dict, List, Optional, Set logger = logging.getLogger(__name__) +def _module_registers_tools(module_path: Path) -> bool: + """Return True when the module contains a direct ``registry.register(...)`` call.""" + try: + source = module_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(module_path)) + except (OSError, SyntaxError): + return False + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "register" + and isinstance(func.value, ast.Name) + and func.value.id == "registry" + ): + return True + return False + + +def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: + """Import built-in self-registering tool modules and return their module names.""" + tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent + module_names = [ + f"tools.{path.stem}" + for path in sorted(tools_path.glob("*.py")) + if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"} + and _module_registers_tools(path) + ] + + imported: List[str] = [] + for mod_name in module_names: + try: + importlib.import_module(mod_name) + imported.append(mod_name) + except Exception as e: + logger.warning("Could not import tool module %s: %s", mod_name, e) + return imported + + class ToolEntry: """Metadata for a single registered tool.""" From fc6cb5b970f006dba448941ce5b3888fc36662fb Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 20:51:55 -0700 Subject: [PATCH 142/849] fix: tighten AST check to module-level only The original tree-wide ast.walk() would match registry.register() calls inside functions too. Restrict to top-level ast.Expr statements so helper modules that call registry.register() inside a function are never picked up as tool modules. --- tools/registry.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tools/registry.py b/tools/registry.py index 53939047b..e6d554e2b 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -25,26 +25,32 @@ from typing import Callable, Dict, List, Optional, Set logger = logging.getLogger(__name__) +def _is_registry_register_call(node: ast.AST) -> bool: + """Return True when *node* is a ``registry.register(...)`` call expression.""" + if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call): + return False + func = node.value.func + return ( + isinstance(func, ast.Attribute) + and func.attr == "register" + and isinstance(func.value, ast.Name) + and func.value.id == "registry" + ) + + def _module_registers_tools(module_path: Path) -> bool: - """Return True when the module contains a direct ``registry.register(...)`` call.""" + """Return True when the module contains a top-level ``registry.register(...)`` call. + + Only inspects module-body statements so that helper modules which happen + to call ``registry.register()`` inside a function are not picked up. + """ try: source = module_path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(module_path)) except (OSError, SyntaxError): return False - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - func = node.func - if ( - isinstance(func, ast.Attribute) - and func.attr == "register" - and isinstance(func.value, ast.Name) - and func.value.id == "registry" - ): - return True - return False + return any(_is_registry_register_call(stmt) for stmt in tree.body) def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: From ef04de3e9851c1349e1a295eb3056557ab9e49e6 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 21:03:34 -0700 Subject: [PATCH 143/849] docs: update tool-adding instructions for auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: 3 files → 2 files, remove _discover_tools() step - adding-tools.md: remove Step 3, note auto-discovery - architecture.md: update discovery description - tools-runtime.md: replace manual list with discover_builtin_tools() docs - hermes-agent skill: remove manual import step --- AGENTS.md | 8 ++-- .../hermes-agent/SKILL.md | 4 +- website/docs/developer-guide/adding-tools.md | 19 +++------- website/docs/developer-guide/architecture.md | 2 +- website/docs/developer-guide/tools-runtime.md | 38 ++++++------------- 5 files changed, 24 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4b998f5e..c5757cc52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ source venv/bin/activate # ALWAYS activate before running Python ``` hermes-agent/ ├── run_agent.py # AIAgent class — core conversation loop -├── model_tools.py # Tool orchestration, _discover_tools(), handle_function_call() +├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call() ├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list ├── cli.py # HermesCLI class — interactive CLI orchestrator ├── hermes_state.py # SessionDB — SQLite session store (FTS5 search) @@ -181,7 +181,7 @@ if canonical == "mycommand": ## Adding New Tools -Requires changes in **3 files**: +Requires changes in **2 files**: **1. Create `tools/your_tool.py`:** ```python @@ -204,9 +204,9 @@ registry.register( ) ``` -**2. Add import** in `model_tools.py` `_discover_tools()` list. +**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. -**3. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. +Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string. diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 9e0b412f5..77e1b1d18 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -650,9 +650,9 @@ registry.register( ) ``` -**2. Add import** in `model_tools.py` → `_discover_tools()` list. +**2. Add to `toolsets.py`** → `_HERMES_CORE_TOOLS` list. -**3. Add to `toolsets.py`** → `_HERMES_CORE_TOOLS` list. +Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual list needed. All handlers must return JSON strings. Use `get_hermes_home()` for paths, never hardcode `~/.hermes`. diff --git a/website/docs/developer-guide/adding-tools.md b/website/docs/developer-guide/adding-tools.md index 76f8477e3..497202bfc 100644 --- a/website/docs/developer-guide/adding-tools.md +++ b/website/docs/developer-guide/adding-tools.md @@ -14,11 +14,12 @@ Make it a **Tool** when it requires end-to-end integration with API keys, custom ## Overview -Adding a tool touches **3 files**: +Adding a tool touches **2 files**: 1. **`tools/your_tool.py`** — handler, schema, check function, `registry.register()` call 2. **`toolsets.py`** — add tool name to `_HERMES_CORE_TOOLS` (or a specific toolset) -3. **`model_tools.py`** — add `"tools.your_tool"` to the `_discover_tools()` list + +Any `tools/*.py` file with a top-level `registry.register()` call is auto-discovered at startup — no manual import list required. ## Step 1: Create the Tool File @@ -124,19 +125,9 @@ _HERMES_CORE_TOOLS = [ }, ``` -## Step 3: Add Discovery Import +## ~~Step 3: Add Discovery Import~~ (No longer needed) -In `model_tools.py`, add the module to the `_discover_tools()` list: - -```python -def _discover_tools(): - _modules = [ - ... - "tools.weather_tool", # <-- add here - ] -``` - -This import triggers the `registry.register()` call at the bottom of your tool file. +Tool modules with a top-level `registry.register()` call are auto-discovered by `discover_builtin_tools()` in `tools/registry.py`. No manual import list to maintain — just create your file in `tools/` and it's picked up at startup. ## Async Handlers diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index eec24815b..5b881c7e2 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -275,4 +275,4 @@ model_tools.py (imports tools/registry + triggers tool discovery) run_agent.py, cli.py, batch_runner.py, environments/ ``` -This chain means tool registration happens at import time, before any agent instance is created. Adding a new tool requires an import in `model_tools.py`'s `_discover_tools()` list. +This chain means tool registration happens at import time, before any agent instance is created. Any `tools/*.py` file with a top-level `registry.register()` call is auto-discovered — no manual import list needed. diff --git a/website/docs/developer-guide/tools-runtime.md b/website/docs/developer-guide/tools-runtime.md index 8e349a505..851ad6bc9 100644 --- a/website/docs/developer-guide/tools-runtime.md +++ b/website/docs/developer-guide/tools-runtime.md @@ -42,37 +42,23 @@ registry.register( Each call creates a `ToolEntry` stored in the singleton `ToolRegistry._tools` dict keyed by tool name. If a name collision occurs across toolsets, a warning is logged and the later registration wins. -### Discovery: `_discover_tools()` +### Discovery: `discover_builtin_tools()` -When `model_tools.py` is imported, it calls `_discover_tools()` which imports every tool module in order: +When `model_tools.py` is imported, it calls `discover_builtin_tools()` from `tools/registry.py`. This function scans every `tools/*.py` file using AST parsing to find modules that contain top-level `registry.register()` calls, then imports them: ```python -_modules = [ - "tools.web_tools", - "tools.terminal_tool", - "tools.file_tools", - "tools.vision_tools", - "tools.mixture_of_agents_tool", - "tools.image_generation_tool", - "tools.skills_tool", - "tools.skill_manager_tool", - "tools.browser_tool", - "tools.cronjob_tools", - "tools.rl_training_tool", - "tools.tts_tool", - "tools.todo_tool", - "tools.memory_tool", - "tools.session_search_tool", - "tools.clarify_tool", - "tools.code_execution_tool", - "tools.delegate_tool", - "tools.process_registry", - "tools.send_message_tool", - # "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin - "tools.homeassistant_tool", -] +# tools/registry.py (simplified) +def discover_builtin_tools(tools_dir=None): + tools_path = Path(tools_dir) if tools_dir else Path(__file__).parent + for path in sorted(tools_path.glob("*.py")): + if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}: + continue + if _module_registers_tools(path): # AST check for top-level registry.register() + importlib.import_module(f"tools.{path.stem}") ``` +This auto-discovery means new tool files are picked up automatically — no manual list to maintain. The AST check only matches top-level `registry.register()` calls (not calls inside functions), so helper modules in `tools/` are not imported. + Each import triggers the module's `registry.register()` calls. Errors in optional tools (e.g., missing `fal_client` for image generation) are caught and logged — they don't prevent other tools from loading. After core tool discovery, MCP tools and plugin tools are also discovered: From ba24f058ed34f5d6531246a87904601225c302b1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 21:06:00 -0700 Subject: [PATCH 144/849] docs: fix stale docstring reference to _discover_tools in mcp_tool.py --- tools/mcp_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index fa8b945ca..50655fa38 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2036,7 +2036,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: def discover_mcp_tools() -> List[str]: """Entry point: load config, connect to MCP servers, register tools. - Called from ``model_tools._discover_tools()``. Safe to call even when + Called from ``model_tools`` after ``discover_builtin_tools()``. Safe to call even when the ``mcp`` package is not installed (returns empty list). Idempotent for already-connected servers. If some servers failed on a From c5688e7c8ba4f46a0cbfad0b0be3a5ac5616350b Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 21:15:12 -0700 Subject: [PATCH 145/849] fix(gateway): break compression-exhaustion infinite loop and auto-reset session (#9893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When compression fails after max attempts, the agent returns {completed: False, partial: True} but was missing the 'failed' flag. The gateway's agent_failed_early guard checked for 'failed' AND 'not final_response', but _run_agent_blocking always converts errors to final_response — making the guard dead code. This caused the oversized session to persist, creating an infinite fail loop where every subsequent message hits the same compression failure. Changes: - run_agent.py: add 'failed: True' and 'compression_exhausted: True' to all 5 compression-exhaustion return paths - gateway/run.py (_run_agent_blocking): forward 'failed' and 'compression_exhausted' flags through to the caller - gateway/run.py (_handle_message_with_agent): fix agent_failed_early to check bool(failed) without the broken 'not final_response' clause; auto-reset the session when compression is exhausted so the next message starts fresh - Update tests to match new guard logic and add TestCompressionExhaustedFlag test class Closes #9893 --- gateway/run.py | 31 +++++++--- run_agent.py | 20 ++++-- .../test_1630_context_overflow_loop.py | 62 +++++++++++++------ 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index c59e9dff1..1bef295c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3898,14 +3898,11 @@ class GatewayRunner: # intermediate reasoning) so sessions can be resumed with full context # and transcripts are useful for debugging and training data. # - # IMPORTANT: When the agent failed before producing any response - # (e.g. context-overflow 400), do NOT persist the user's message. + # IMPORTANT: When the agent failed (e.g. context-overflow 400, + # compression exhausted), do NOT persist the user's message. # Persisting it would make the session even larger, causing the - # same failure on the next attempt — an infinite loop. (#1630) - agent_failed_early = ( - agent_result.get("failed") - and not agent_result.get("final_response") - ) + # same failure on the next attempt — an infinite loop. (#1630, #9893) + agent_failed_early = bool(agent_result.get("failed")) if agent_failed_early: logger.info( "Skipping transcript persistence for failed request in " @@ -3913,6 +3910,24 @@ class GatewayRunner: session_entry.session_id, ) + # When compression is exhausted, the session is permanently too + # large to process. Auto-reset it so the next message starts + # fresh instead of replaying the same oversized context in an + # infinite fail loop. (#9893) + if agent_result.get("compression_exhausted") and session_entry and session_key: + logger.info( + "Auto-resetting session %s after compression exhaustion.", + session_entry.session_id, + ) + self.session_store.reset_session(session_key) + self._evict_cached_agent(session_key) + self._session_model_overrides.pop(session_key, None) + response = (response or "") + ( + "\n\n🔄 Session auto-reset — the conversation exceeded the " + "maximum context size and could not be compressed further. " + "Your next message will start a fresh session." + ) + ts = datetime.now().isoformat() # If this is a fresh session (no history), write the full tool @@ -8626,6 +8641,8 @@ class GatewayRunner: "final_response": error_msg, "messages": result.get("messages", []), "api_calls": result.get("api_calls", 0), + "failed": result.get("failed", False), + "compression_exhausted": result.get("compression_exhausted", False), "tools": tools_holder[0] or [], "history_offset": len(agent_history), "last_prompt_tokens": _last_prompt_toks, diff --git a/run_agent.py b/run_agent.py index 0814a8b49..080156051 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9312,7 +9312,9 @@ class AIAgent: "completed": False, "api_calls": api_call_count, "error": f"Request payload too large: max compression attempts ({max_compression_attempts}) reached.", - "partial": True + "partial": True, + "failed": True, + "compression_exhausted": True, } self._emit_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...") @@ -9341,7 +9343,9 @@ class AIAgent: "completed": False, "api_calls": api_call_count, "error": "Request payload too large (413). Cannot compress further.", - "partial": True + "partial": True, + "failed": True, + "compression_exhausted": True, } # Check for context-length errors BEFORE generic 4xx handler. @@ -9392,7 +9396,9 @@ class AIAgent: "completed": False, "api_calls": api_call_count, "error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.", - "partial": True + "partial": True, + "failed": True, + "compression_exhausted": True, } restart_with_compressed_messages = True break @@ -9442,7 +9448,9 @@ class AIAgent: "completed": False, "api_calls": api_call_count, "error": f"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.", - "partial": True + "partial": True, + "failed": True, + "compression_exhausted": True, } self._emit_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...") @@ -9473,7 +9481,9 @@ class AIAgent: "completed": False, "api_calls": api_call_count, "error": f"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.", - "partial": True + "partial": True, + "failed": True, + "compression_exhausted": True, } # Check for non-retryable client errors. The classifier diff --git a/tests/run_agent/test_1630_context_overflow_loop.py b/tests/run_agent/test_1630_context_overflow_loop.py index d087fee4f..c33aaa967 100644 --- a/tests/run_agent/test_1630_context_overflow_loop.py +++ b/tests/run_agent/test_1630_context_overflow_loop.py @@ -136,33 +136,29 @@ class TestGatewaySkipsPersistenceOnFailure: the gateway should NOT persist messages to the transcript.""" def test_agent_failed_early_detected(self): - """The agent_failed_early flag is True when failed=True and - no final_response.""" + """The agent_failed_early flag is True when failed=True, + regardless of final_response.""" agent_result = { "failed": True, "final_response": None, "messages": [], "error": "Non-retryable client error", } - agent_failed_early = ( - agent_result.get("failed") - and not agent_result.get("final_response") - ) + agent_failed_early = bool(agent_result.get("failed")) assert agent_failed_early - def test_agent_with_response_not_failed_early(self): - """When the agent has a final_response, it's not a failed-early - scenario even if failed=True.""" + def test_agent_failed_with_error_response_still_detected(self): + """When _run_agent_blocking converts an error to final_response, + the failed flag should still trigger agent_failed_early. This + was the core bug in #9893 — the old guard checked + ``not final_response`` which was always truthy after conversion.""" agent_result = { "failed": True, - "final_response": "Here is a partial response", + "final_response": "⚠️ Request payload too large: max compression attempts reached.", "messages": [], } - agent_failed_early = ( - agent_result.get("failed") - and not agent_result.get("final_response") - ) - assert not agent_failed_early + agent_failed_early = bool(agent_result.get("failed")) + assert agent_failed_early def test_successful_agent_not_failed_early(self): """A successful agent result should not trigger skip.""" @@ -170,13 +166,41 @@ class TestGatewaySkipsPersistenceOnFailure: "final_response": "Hello!", "messages": [{"role": "assistant", "content": "Hello!"}], } - agent_failed_early = ( - agent_result.get("failed") - and not agent_result.get("final_response") - ) + agent_failed_early = bool(agent_result.get("failed")) assert not agent_failed_early +class TestCompressionExhaustedFlag: + """When compression is exhausted, the agent should set both + failed=True and compression_exhausted=True so the gateway can + auto-reset the session. (#9893)""" + + def test_compression_exhausted_returns_carry_flag(self): + """Simulate the return dict from a compression-exhausted agent.""" + agent_result = { + "messages": [], + "completed": False, + "api_calls": 3, + "error": "Request payload too large: max compression attempts (3) reached.", + "partial": True, + "failed": True, + "compression_exhausted": True, + } + assert agent_result.get("failed") + assert agent_result.get("compression_exhausted") + + def test_normal_failure_not_compression_exhausted(self): + """Non-compression failures should not have compression_exhausted.""" + agent_result = { + "messages": [], + "completed": False, + "failed": True, + "error": "Invalid API response after 3 retries", + } + assert agent_result.get("failed") + assert not agent_result.get("compression_exhausted") + + # --------------------------------------------------------------------------- # Test 3: Context-overflow error messages # --------------------------------------------------------------------------- From 8548893d14724b8f1e1e74ca9315a86fc3ee8c08 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:20:37 -0700 Subject: [PATCH 146/849] =?UTF-8?q?feat:=20entry-level=20Podman=20support?= =?UTF-8?q?=20=E2=80=94=20find=5Fdocker()=20+=20rootless=20entrypoint=20(#?= =?UTF-8?q?10066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - find_docker() now checks HERMES_DOCKER_BINARY env var first, then docker on PATH, then podman on PATH, then macOS known locations - Entrypoint respects HERMES_HOME env var (was hardcoded to /opt/data) - Entrypoint uses groupmod -o to tolerate non-unique GIDs (fixes macOS GID 20 conflict with Debian's dialout group) - Entrypoint makes chown best-effort so rootless Podman continues instead of failing with 'Operation not permitted' - 5 new tests covering env var override, podman fallback, precedence Based on work by alanjds (PR #3996) and malaiwah (PR #8115). Closes #4084. --- .env.example | 4 +++ docker/entrypoint.sh | 19 +++++++---- tests/tools/test_docker_find.py | 56 +++++++++++++++++++++++++++++++++ tools/environments/docker.py | 28 ++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) mode change 100644 => 100755 docker/entrypoint.sh diff --git a/.env.example b/.env.example index 0317296ba..76be6ce26 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,10 @@ # Only override here if you need to force a backend without touching config.yaml: # TERMINAL_ENV=local +# Override the container runtime binary (e.g. to use Podman instead of Docker). +# Useful on systems where Docker's storage driver is broken or unavailable. +# HERMES_DOCKER_BINARY=/usr/local/bin/podman + # Container images (for singularity/docker/modal backends) # TERMINAL_DOCKER_IMAGE=nikolaik/python-nodejs:python3.11-nodejs20 # TERMINAL_SINGULARITY_IMAGE=docker://nikolaik/python-nodejs:python3.11-nodejs20 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 index dc1edd32c..c46497dcc --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,13 +1,14 @@ #!/bin/bash -# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes. +# Docker/Podman entrypoint: bootstrap config files into the mounted volume, then run hermes. set -e -HERMES_HOME="/opt/data" +HERMES_HOME="${HERMES_HOME:-/opt/data}" INSTALL_DIR="/opt/hermes" # --- Privilege dropping via gosu --- -# When started as root (the default), optionally remap the hermes user/group -# to match host-side ownership, fix volume permissions, then re-exec as hermes. +# When started as root (the default for Docker, or fakeroot in rootless Podman), +# optionally remap the hermes user/group to match host-side ownership, fix volume +# permissions, then re-exec as hermes. if [ "$(id -u)" = "0" ]; then if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "Changing hermes UID to $HERMES_UID" @@ -16,13 +17,19 @@ if [ "$(id -u)" = "0" ]; then if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then echo "Changing hermes GID to $HERMES_GID" - groupmod -g "$HERMES_GID" hermes + # -o allows non-unique GID (e.g. macOS GID 20 "staff" may already exist + # as "dialout" in the Debian-based container image) + groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true fi actual_hermes_uid=$(id -u hermes) if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing" - chown -R hermes:hermes "$HERMES_HOME" + # In rootless Podman the container's "root" is mapped to an unprivileged + # host UID — chown will fail. That's fine: the volume is already owned + # by the mapped user on the host side. + chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \ + echo "Warning: chown failed (rootless container?) — continuing anyway" fi echo "Dropping root privileges" diff --git a/tests/tools/test_docker_find.py b/tests/tools/test_docker_find.py index c1fb58a3e..0cf9c3208 100644 --- a/tests/tools/test_docker_find.py +++ b/tests/tools/test_docker_find.py @@ -46,3 +46,59 @@ class TestFindDocker: with patch("tools.environments.docker.shutil.which", return_value=None): second = docker_mod.find_docker() assert first == second == "/usr/local/bin/docker" + + def test_env_var_override_takes_precedence(self, tmp_path): + """HERMES_DOCKER_BINARY overrides PATH and known-location discovery.""" + fake_binary = tmp_path / "podman" + fake_binary.write_text("#!/bin/sh\n") + fake_binary.chmod(0o755) + + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": str(fake_binary)}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == str(fake_binary) + + def test_env_var_override_ignored_if_not_executable(self, tmp_path): + """Non-executable HERMES_DOCKER_BINARY falls through to normal discovery.""" + fake_binary = tmp_path / "podman" + fake_binary.write_text("#!/bin/sh\n") + fake_binary.chmod(0o644) # not executable + + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": str(fake_binary)}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" + + def test_env_var_override_ignored_if_nonexistent(self): + """Non-existent HERMES_DOCKER_BINARY path falls through.""" + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": "/nonexistent/podman"}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" + + def test_podman_on_path_used_when_docker_missing(self): + """When docker is not on PATH, podman is tried next.""" + def which_side_effect(name): + if name == "docker": + return None + if name == "podman": + return "/usr/bin/podman" + return None + + with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect), \ + patch("tools.environments.docker._DOCKER_SEARCH_PATHS", []): + result = docker_mod.find_docker() + assert result == "/usr/bin/podman" + + def test_docker_preferred_over_podman(self): + """When both docker and podman are on PATH, docker wins.""" + def which_side_effect(name): + if name == "docker": + return "/usr/bin/docker" + if name == "podman": + return "/usr/bin/podman" + return None + + with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 2341778f4..d2ea5c964 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -99,23 +99,41 @@ def _load_hermes_env_vars() -> dict[str, str]: def find_docker() -> Optional[str]: - """Locate the docker CLI binary. + """Locate the docker (or podman) CLI binary. - Checks ``shutil.which`` first (respects PATH), then probes well-known - install locations on macOS where Docker Desktop may not be in PATH - (e.g. when running as a gateway service via launchd). + Resolution order: + 1. ``HERMES_DOCKER_BINARY`` env var — explicit override (e.g. ``/usr/bin/podman``) + 2. ``docker`` on PATH via ``shutil.which`` + 3. ``podman`` on PATH via ``shutil.which`` + 4. Well-known macOS Docker Desktop install locations - Returns the absolute path, or ``None`` if docker cannot be found. + Returns the absolute path, or ``None`` if neither runtime can be found. """ global _docker_executable if _docker_executable is not None: return _docker_executable + # 1. Explicit override via env var (e.g. for Podman on immutable distros) + override = os.getenv("HERMES_DOCKER_BINARY") + if override and os.path.isfile(override) and os.access(override, os.X_OK): + _docker_executable = override + logger.info("Using HERMES_DOCKER_BINARY override: %s", override) + return override + + # 2. docker on PATH found = shutil.which("docker") if found: _docker_executable = found return found + # 3. podman on PATH (drop-in compatible for our use case) + found = shutil.which("podman") + if found: + _docker_executable = found + logger.info("Using podman as container runtime: %s", found) + return found + + # 4. Well-known macOS Docker Desktop locations for path in _DOCKER_SEARCH_PATHS: if os.path.isfile(path) and os.access(path, os.X_OK): _docker_executable = path From a8b7db35b2173f9adef77fd46a0e06a080b2149a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:07:28 -0700 Subject: [PATCH 147/849] fix: interrupt agent immediately when user messages during active run (#10068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends a message while the agent is executing a task on the gateway, the agent is now interrupted immediately — not silently queued. Previously, messages were stored in _pending_messages with zero feedback to the user, potentially leaving them waiting 1+ hours. Root cause: Level 1 guard (base.py) intercepted all messages for active sessions and returned with no response. Level 2 (gateway/run.py) which calls agent.interrupt() was never reached. Fix: Expand _handle_active_session_busy_message to handle the normal (non-draining) case: 1. Call running_agent.interrupt(text) to abort in-flight tool calls and exit the agent loop at the next check point 2. Store the message as pending so it becomes the next turn once the interrupted run returns 3. Send a brief ack: 'Interrupting current task (10 min elapsed, iteration 21/60, running: terminal). I'll respond shortly.' 4. Debounce acks to once per 30s to avoid spam on rapid messages Reported by @Lonely__MH. --- gateway/run.py | 106 +++++++-- tests/gateway/test_busy_session_ack.py | 293 +++++++++++++++++++++++++ 2 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 tests/gateway/test_busy_session_ack.py diff --git a/gateway/run.py b/gateway/run.py index 1bef295c3..cfb4af82e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -573,6 +573,7 @@ class GatewayRunner: self._running_agents: Dict[str, Any] = {} self._running_agents_ts: Dict[str, float] = {} # start timestamp per session self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt + self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce) # Cache AIAgent instances per session to preserve prompt caching. # Without this, a new AIAgent is created per message, rebuilding the @@ -1329,26 +1330,100 @@ class GatewayRunner: merge_pending_message_event(adapter._pending_messages, session_key, event) async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool: - if not self._draining: - return False + # --- Draining case (gateway restarting/stopping) --- + if self._draining: + adapter = self.adapters.get(event.source.platform) + if not adapter: + return True + + thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None + if self._queue_during_drain_enabled(): + self._queue_or_replace_pending_event(session_key, event) + message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." + else: + message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." + + await adapter._send_with_retry( + chat_id=event.source.chat_id, + content=message, + reply_to=event.message_id, + metadata=thread_meta, + ) + return True + + # --- Normal busy case (agent actively running a task) --- + # The user sent a message while the agent is working. Interrupt the + # agent immediately so it stops the current tool-calling loop and + # processes the new message. The pending message is stored in the + # adapter so the base adapter picks it up once the interrupted run + # returns. A brief ack tells the user what's happening (debounced + # to avoid spam when they fire multiple messages quickly). adapter = self.adapters.get(event.source.platform) if not adapter: - return True + return False # let default path handle it + + # Store the message so it's processed as the next turn after the + # interrupt causes the current run to exit. + from gateway.platforms.base import merge_pending_message_event + merge_pending_message_event(adapter._pending_messages, session_key, event) + + # Interrupt the running agent — this aborts in-flight tool calls and + # causes the agent loop to exit at the next check point. + running_agent = self._running_agents.get(session_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + try: + running_agent.interrupt(event.text) + except Exception: + pass # don't let interrupt failure block the ack + + # Debounce: only send an acknowledgment once every 30 seconds per session + # to avoid spamming the user when they send multiple messages quickly + _BUSY_ACK_COOLDOWN = 30 + now = time.time() + last_ack = self._busy_ack_ts.get(session_key, 0) + if now - last_ack < _BUSY_ACK_COOLDOWN: + return True # interrupt sent, ack already delivered recently + + self._busy_ack_ts[session_key] = now + + # Build a status-rich acknowledgment + status_parts = [] + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + try: + summary = running_agent.get_activity_summary() + iteration = summary.get("api_call_count", 0) + max_iter = summary.get("max_iterations", 0) + current_tool = summary.get("current_tool") + start_ts = self._running_agents_ts.get(session_key, 0) + if start_ts: + elapsed_min = int((now - start_ts) / 60) + if elapsed_min > 0: + status_parts.append(f"{elapsed_min} min elapsed") + if max_iter: + status_parts.append(f"iteration {iteration}/{max_iter}") + if current_tool: + status_parts.append(f"running: {current_tool}") + except Exception: + pass + + status_detail = f" ({', '.join(status_parts)})" if status_parts else "" + message = ( + f"⚡ Interrupting current task{status_detail}. " + f"I'll respond to your message shortly." + ) thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None - if self._queue_during_drain_enabled(): - self._queue_or_replace_pending_event(session_key, event) - message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." - else: - message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." + try: + await adapter._send_with_retry( + chat_id=event.source.chat_id, + content=message, + reply_to=event.message_id, + metadata=thread_meta, + ) + except Exception as e: + logger.debug("Failed to send busy-ack: %s", e) - await adapter._send_with_retry( - chat_id=event.source.chat_id, - content=message, - reply_to=event.message_id, - metadata=thread_meta, - ) return True async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]: @@ -2237,6 +2312,8 @@ class GatewayRunner: self._running_agents.clear() self._pending_messages.clear() self._pending_approvals.clear() + if hasattr(self, '_busy_ack_ts'): + self._busy_ack_ts.clear() self._shutdown_event.set() # Global cleanup: kill any remaining tool subprocesses not tied @@ -2721,6 +2798,7 @@ class GatewayRunner: ) del self._running_agents[_quick_key] self._running_agents_ts.pop(_quick_key, None) + self._busy_ack_ts.pop(_quick_key, None) if _quick_key in self._running_agents: if event.get_command() == "status": diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py new file mode 100644 index 000000000..07fe5fa27 --- /dev/null +++ b/tests/gateway/test_busy_session_ack.py @@ -0,0 +1,293 @@ +"""Tests for busy-session acknowledgment when user sends messages during active agent runs. + +Verifies that users get an immediate status response instead of total silence +when the agent is working on a task. See PR fix for the @Lonely__MH report. +""" +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Minimal stubs so we can import gateway code without heavy deps +# --------------------------------------------------------------------------- +import sys, types + +_tg = types.ModuleType("telegram") +_tg.constants = types.ModuleType("telegram.constants") +_ct = MagicMock() +_ct.SUPERGROUP = "supergroup" +_ct.GROUP = "group" +_ct.PRIVATE = "private" +_tg.constants.ChatType = _ct +sys.modules.setdefault("telegram", _tg) +sys.modules.setdefault("telegram.constants", _tg.constants) +sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext")) + +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SessionSource, + build_session_key, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_event(text="hello", chat_id="123", platform_val="telegram"): + """Build a minimal MessageEvent.""" + source = SessionSource( + platform=MagicMock(value=platform_val), + chat_id=chat_id, + chat_type="private", + user_id="user1", + ) + evt = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id="msg1", + ) + return evt + + +def _make_runner(): + """Build a minimal GatewayRunner-like object for testing.""" + from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL + + runner = object.__new__(GatewayRunner) + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._busy_ack_ts = {} + runner._draining = False + runner.adapters = {} + runner.config = MagicMock() + runner.session_store = None + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + return runner, _AGENT_PENDING_SENTINEL + + +def _make_adapter(platform_val="telegram"): + """Build a minimal adapter mock.""" + adapter = MagicMock() + adapter._pending_messages = {} + adapter._send_with_retry = AsyncMock() + adapter.config = MagicMock() + adapter.config.extra = {} + adapter.platform = MagicMock(value=platform_val) + return adapter + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestBusySessionAck: + """User sends a message while agent is running — should get acknowledgment.""" + + @pytest.mark.asyncio + async def test_sends_ack_when_agent_running(self): + """First message during busy session should get a status ack.""" + runner, sentinel = _make_runner() + adapter = _make_adapter() + + event = _make_event(text="Are you working?") + sk = build_session_key(event.source) + + # Simulate running agent + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 21, + "max_iterations": 60, + "current_tool": "terminal", + "last_activity_ts": time.time(), + "last_activity_desc": "terminal", + "seconds_since_activity": 1.0, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 600 # 10 min ago + runner.adapters[event.source.platform] = adapter + + result = await runner._handle_active_session_busy_message(event, sk) + + assert result is True # handled + # Verify ack was sent + adapter._send_with_retry.assert_called_once() + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "") + if not content and call_kwargs.args: + # positional args + content = str(call_kwargs) + assert "Interrupting" in content or "respond" in content + assert "/stop" not in content # no need — we ARE interrupting + + # Verify message was queued in adapter pending + assert sk in adapter._pending_messages + + # Verify agent interrupt was called + agent.interrupt.assert_called_once_with("Are you working?") + + @pytest.mark.asyncio + async def test_debounce_suppresses_rapid_acks(self): + """Second message within 30s should NOT send another ack.""" + runner, sentinel = _make_runner() + adapter = _make_adapter() + + event1 = _make_event(text="hello?") + # Reuse the same source so platform mock matches + event2 = MessageEvent( + text="still there?", + message_type=MessageType.TEXT, + source=event1.source, + message_id="msg2", + ) + sk = build_session_key(event1.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 5, + "max_iterations": 60, + "current_tool": None, + "last_activity_ts": time.time(), + "last_activity_desc": "api_call", + "seconds_since_activity": 0.5, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 60 + runner.adapters[event1.source.platform] = adapter + + # First message — should get ack + result1 = await runner._handle_active_session_busy_message(event1, sk) + assert result1 is True + assert adapter._send_with_retry.call_count == 1 + + # Second message within cooldown — should be queued but no ack + result2 = await runner._handle_active_session_busy_message(event2, sk) + assert result2 is True + assert adapter._send_with_retry.call_count == 1 # still 1, no new ack + + # But interrupt should still be called for both + assert agent.interrupt.call_count == 2 + + @pytest.mark.asyncio + async def test_ack_after_cooldown_expires(self): + """After 30s cooldown, a new message should send a fresh ack.""" + runner, sentinel = _make_runner() + adapter = _make_adapter() + + event = _make_event(text="hello?") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 10, + "max_iterations": 60, + "current_tool": "web_search", + "last_activity_ts": time.time(), + "last_activity_desc": "tool", + "seconds_since_activity": 0.5, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 120 + runner.adapters[event.source.platform] = adapter + + # First ack + await runner._handle_active_session_busy_message(event, sk) + assert adapter._send_with_retry.call_count == 1 + + # Fake that cooldown expired + runner._busy_ack_ts[sk] = time.time() - 31 + + # Second ack should go through + await runner._handle_active_session_busy_message(event, sk) + assert adapter._send_with_retry.call_count == 2 + + @pytest.mark.asyncio + async def test_includes_status_detail(self): + """Ack message should include iteration and tool info when available.""" + runner, sentinel = _make_runner() + adapter = _make_adapter() + + event = _make_event(text="yo") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 21, + "max_iterations": 60, + "current_tool": "terminal", + "last_activity_ts": time.time(), + "last_activity_desc": "terminal", + "seconds_since_activity": 0.5, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 600 # 10 min + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + assert "21/60" in content # iteration + assert "terminal" in content # current tool + assert "10 min" in content # elapsed + + @pytest.mark.asyncio + async def test_draining_still_works(self): + """Draining case should still produce the drain-specific message.""" + runner, sentinel = _make_runner() + runner._draining = True + adapter = _make_adapter() + + event = _make_event(text="hello") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + # Mock the drain-specific methods + runner._queue_during_drain_enabled = lambda: False + runner._status_action_gerund = lambda: "restarting" + + result = await runner._handle_active_session_busy_message(event, sk) + assert result is True + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + assert "restarting" in content + + @pytest.mark.asyncio + async def test_pending_sentinel_no_interrupt(self): + """When agent is PENDING_SENTINEL, don't call interrupt (it has no method).""" + runner, sentinel = _make_runner() + adapter = _make_adapter() + + event = _make_event(text="hey") + sk = build_session_key(event.source) + + runner._running_agents[sk] = sentinel + runner._running_agents_ts[sk] = time.time() + runner.adapters[event.source.platform] = adapter + + result = await runner._handle_active_session_busy_message(event, sk) + assert result is True + # Should still send ack + adapter._send_with_retry.assert_called_once() + + @pytest.mark.asyncio + async def test_no_adapter_falls_through(self): + """If adapter is missing, return False so default path handles it.""" + runner, sentinel = _make_runner() + + event = _make_event(text="hello") + sk = build_session_key(event.source) + + # No adapter registered + runner._running_agents[sk] = MagicMock() + + result = await runner._handle_active_session_busy_message(event, sk) + assert result is False # not handled, let default path try From 93fe4ead83bb72586118a91c34076ca615ae3368 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:14:27 -0700 Subject: [PATCH 148/849] fix: warn on invalid context_length format in config.yaml (#10067) Previously, non-integer context_length values (e.g. '256K') in config.yaml were silently ignored, causing the agent to fall back to 128K auto-detection with no user feedback. This was confusing for users with custom LiteLLM endpoints expecting larger context. Now prints a clear stderr warning and logs at WARNING level when model.context_length or custom_providers[].models..context_length cannot be parsed as an integer, telling users to use plain integers (e.g. 256000 instead of '256K'). Reported by community user ChFarhan via Discord. --- run_agent.py | 28 ++++- .../test_invalid_context_length_warning.py | 111 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/run_agent/test_invalid_context_length_warning.py diff --git a/run_agent.py b/run_agent.py index 080156051..e98a6e798 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1268,6 +1268,19 @@ class AIAgent: try: _config_context_length = int(_config_context_length) except (TypeError, ValueError): + logger.warning( + "Invalid model.context_length in config.yaml: %r — " + "must be a plain integer (e.g. 256000, not '256K'). " + "Falling back to auto-detection.", + _config_context_length, + ) + import sys + print( + f"\n⚠ Invalid model.context_length in config.yaml: {_config_context_length!r}\n" + f" Must be a plain integer (e.g. 256000, not '256K').\n" + f" Falling back to auto-detected context window.\n", + file=sys.stderr, + ) _config_context_length = None # Store for reuse in switch_model (so config override persists across model switches) @@ -1296,7 +1309,20 @@ class AIAgent: try: _config_context_length = int(_cp_ctx) except (TypeError, ValueError): - pass + logger.warning( + "Invalid context_length for model %r in " + "custom_providers: %r — must be a plain " + "integer (e.g. 256000, not '256K'). " + "Falling back to auto-detection.", + self.model, _cp_ctx, + ) + import sys + print( + f"\n⚠ Invalid context_length for model {self.model!r} in custom_providers: {_cp_ctx!r}\n" + f" Must be a plain integer (e.g. 256000, not '256K').\n" + f" Falling back to auto-detected context window.\n", + file=sys.stderr, + ) break # Select context engine: config-driven (like memory providers). diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py new file mode 100644 index 000000000..1ed72c951 --- /dev/null +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -0,0 +1,111 @@ +"""Tests that invalid context_length values in config produce visible warnings.""" + +from unittest.mock import patch, MagicMock, call + + +def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus-4.6"): + """Build an AIAgent with the given model config.""" + cfg = {"model": model_cfg} + if custom_providers is not None: + cfg["custom_providers"] = custom_providers + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("agent.model_metadata.get_model_context_length", return_value=128_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent( + model=model, + api_key="test-key-1234567890", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + return agent + + +def test_valid_integer_context_length_no_warning(): + """Plain integer context_length should work silently.""" + with patch("run_agent.logger") as mock_logger: + agent = _build_agent({"default": "gpt5.4", "provider": "custom", + "base_url": "http://localhost:4000/v1", + "context_length": 256000}) + assert agent._config_context_length == 256000 + # No warning about invalid context_length + for c in mock_logger.warning.call_args_list: + assert "Invalid" not in str(c) + + +def test_string_k_suffix_context_length_warns(): + """context_length: '256K' should warn the user clearly.""" + with patch("run_agent.logger") as mock_logger: + agent = _build_agent({"default": "gpt5.4", "provider": "custom", + "base_url": "http://localhost:4000/v1", + "context_length": "256K"}) + assert agent._config_context_length is None + # Should have warned + warning_calls = [c for c in mock_logger.warning.call_args_list + if "Invalid" in str(c) and "256K" in str(c)] + assert len(warning_calls) == 1 + assert "plain integer" in str(warning_calls[0]) + + +def test_string_numeric_context_length_works(): + """context_length: '256000' (string) should parse fine via int().""" + with patch("run_agent.logger") as mock_logger: + agent = _build_agent({"default": "gpt5.4", "provider": "custom", + "base_url": "http://localhost:4000/v1", + "context_length": "256000"}) + assert agent._config_context_length == 256000 + for c in mock_logger.warning.call_args_list: + assert "Invalid" not in str(c) + + +def test_custom_providers_invalid_context_length_warns(): + """Invalid context_length in custom_providers should warn.""" + custom_providers = [ + { + "name": "LiteLLM", + "base_url": "http://localhost:4000/v1", + "models": { + "gpt5.4": {"context_length": "256K"} + }, + } + ] + with patch("run_agent.logger") as mock_logger: + agent = _build_agent( + {"default": "gpt5.4", "provider": "custom", + "base_url": "http://localhost:4000/v1"}, + custom_providers=custom_providers, + model="gpt5.4", + ) + warning_calls = [c for c in mock_logger.warning.call_args_list + if "Invalid" in str(c) and "256K" in str(c)] + assert len(warning_calls) == 1 + assert "custom_providers" in str(warning_calls[0]) + + +def test_custom_providers_valid_context_length(): + """Valid integer in custom_providers should work silently.""" + custom_providers = [ + { + "name": "LiteLLM", + "base_url": "http://localhost:4000/v1", + "models": { + "gpt5.4": {"context_length": 256000} + }, + } + ] + with patch("run_agent.logger") as mock_logger: + agent = _build_agent( + {"default": "gpt5.4", "provider": "custom", + "base_url": "http://localhost:4000/v1"}, + custom_providers=custom_providers, + model="gpt5.4", + ) + for c in mock_logger.warning.call_args_list: + assert "Invalid" not in str(c) From 50c35dcabe9f6f909630a44cf007ae39d2ddbaf3 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 21:15:53 -0700 Subject: [PATCH 149/849] fix: stale agent timeout, uv venv detection, empty response after tools (#9051, #8620, #9400) Three independent fixes: 1. Reset activity timestamp on cached agent reuse (#9051) When the gateway reuses a cached AIAgent for a new turn, the _last_activity_ts from the previous turn (possibly hours ago) carried over. The inactivity timeout handler immediately saw the agent as idle for hours and killed it. Fix: reset _last_activity_ts, _last_activity_desc, and _api_call_count when retrieving an agent from the cache. 2. Detect uv-managed virtual environments (#8620 sub-issue 1) The systemd unit generator fell back to sys.executable (uv's standalone Python) when running under 'uv run', because sys.prefix == sys.base_prefix (uv doesn't set up traditional venv activation). The generated ExecStart pointed to a Python binary without site-packages, crashing the service on startup. Fix: check VIRTUAL_ENV env var before falling back to sys.executable. uv sets VIRTUAL_ENV even when sys.prefix doesn't reflect the venv. 3. Nudge model to continue after empty post-tool response (#9400) Weaker models (GLM-5, mimo-v2-pro) sometimes return empty responses after tool calls instead of continuing to the next step. The agent silently abandoned the remaining work with '(empty)' or used prior-turn fallback text. Fix: when the model returns empty after tool calls AND there's no prior-turn content to fall back on, inject a one-time user nudge message telling the model to process the tool results and continue. The flag resets after each successful tool round so it can fire again on later rounds. Test plan: 97 gateway + CLI tests pass, 9 venv detection tests pass --- gateway/run.py | 6 ++++++ hermes_cli/gateway.py | 13 +++++++++++- run_agent.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index cfb4af82e..d360d453f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8458,6 +8458,12 @@ class GatewayRunner: cached = _cache.get(session_key) if cached and cached[1] == _sig: agent = cached[0] + # Reset activity timestamp so the inactivity timeout + # handler doesn't see stale idle time from the previous + # turn and immediately kill this agent. (#9051) + agent._last_activity_ts = time.time() + agent._last_activity_desc = "starting new turn (cached)" + agent._api_call_count = 0 logger.debug("Reusing cached agent for session %s", session_key) if agent is None: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 58d9f92ed..6d46bdde6 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -715,7 +715,9 @@ def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. Checks ``sys.prefix`` first (works regardless of the directory name), - then falls back to probing common directory names under PROJECT_ROOT. + then ``VIRTUAL_ENV`` env var (covers uv-managed environments where + sys.prefix == sys.base_prefix), then falls back to probing common + directory names under PROJECT_ROOT. Returns ``None`` when no virtualenv can be found. """ # If we're running inside a virtualenv, sys.prefix points to it. @@ -724,6 +726,15 @@ def _detect_venv_dir() -> Path | None: if venv.is_dir(): return venv + # uv and some other tools set VIRTUAL_ENV without changing sys.prefix. + # This catches `uv run` where sys.prefix == sys.base_prefix but the + # environment IS a venv. (#8620) + _virtual_env = os.environ.get("VIRTUAL_ENV") + if _virtual_env: + venv = Path(_virtual_env) + if venv.is_dir(): + return venv + # Fallback: check common virtualenv directory names under the project root. for candidate in (".venv", "venv"): venv = PROJECT_ROOT / candidate diff --git a/run_agent.py b/run_agent.py index e98a6e798..55b4efaa6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7858,6 +7858,7 @@ class AIAgent: self._incomplete_scratchpad_retries = 0 self._codex_incomplete_retries = 0 self._thinking_prefill_retries = 0 + self._post_tool_empty_retried = False self._last_content_with_tools = None self._mute_post_response = False self._unicode_sanitization_passes = 0 @@ -10131,6 +10132,10 @@ class AIAgent: if _had_prefill: self._thinking_prefill_retries = 0 self._empty_content_retries = 0 + # Successful tool execution — reset the post-tool nudge + # flag so it can fire again if the model goes empty on + # a LATER tool round. + self._post_tool_empty_retried = False messages.append(assistant_msg) self._emit_interim_assistant_message(assistant_msg) @@ -10299,6 +10304,48 @@ class AIAgent: self._response_was_previewed = True break + # ── Post-tool-call empty response nudge ─────────── + # The model returned empty after executing tool calls + # but there's no prior-turn content to fall back on. + # Instead of giving up, nudge the model to continue by + # appending a user-level hint. This is the #9400 case: + # weaker models (GLM-5, etc.) sometimes return empty + # after tool results instead of continuing to the next + # step. One retry with a nudge usually fixes it. + _prior_was_tool = any( + m.get("role") == "tool" + for m in messages[-5:] # check recent messages + ) + if ( + _prior_was_tool + and not getattr(self, "_post_tool_empty_retried", False) + ): + self._post_tool_empty_retried = True + logger.info( + "Empty response after tool calls — nudging model " + "to continue processing" + ) + self._emit_status( + "⚠️ Model returned empty after tool calls — " + "nudging to continue" + ) + # Append the empty assistant message first so the + # message sequence stays valid: + # tool(result) → assistant("(empty)") → user(nudge) + # Without this, we'd have tool → user which most + # APIs reject as an invalid sequence. + assistant_msg["content"] = "(empty)" + messages.append(assistant_msg) + messages.append({ + "role": "user", + "content": ( + "You just executed tool calls but returned an " + "empty response. Please process the tool " + "results above and continue with the task." + ), + }) + continue + # ── Thinking-only prefill continuation ────────── # The model produced structured reasoning (via API # fields) but no visible text content. Rather than From 9855190f23a2354b1c796b83bd58582946485c7d Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Tue, 14 Apr 2026 22:21:04 -0700 Subject: [PATCH 150/849] feat(compressor): smart collapse, dedup, anti-thrashing, template upgrade, hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined salvage of PRs #9661, #9663, #9674, #9677, #9678 by kshitijk4poor. - Smart tool output collapse: informative 1-line summaries replace generic placeholder - Dedup identical tool results via MD5 hash, truncate large tool_call arguments - Anti-thrashing: skip compression after 2 consecutive <10% savings passes - Structured action-log summary template with numbered actions and Active State - Hardening: max_tokens 1.3x cap, multimodal safety, note idempotency, adaptive cooldown Follow-up fixes applied during salvage: - web_extract: reads 'urls' (list) not 'url' (original PR bug) - Multimodal list content guards in dedup and prune passes - Kept 'Relevant Files' section in template (original PR removed it) Skipped PRs #9665 (user msg preservation — duplication risk) and #9675 (dead code). --- agent/context_compressor.py | 314 +++++++++++++++++++++++++++++++----- 1 file changed, 278 insertions(+), 36 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 4163966aa..74bbbd5d0 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -17,7 +17,10 @@ Improvements over v2: - Richer tool call/result detail in summarizer input """ +import hashlib +import json import logging +import re import time from typing import Any, Dict, List, Optional @@ -57,6 +60,128 @@ _CHARS_PER_TOKEN = 4 _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 +def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str: + """Create an informative 1-line summary of a tool call + result. + + Used during the pre-compression pruning pass to replace large tool + outputs with a short but useful description of what the tool did, + rather than a generic placeholder that carries zero information. + + Returns strings like:: + + [terminal] ran `npm test` -> exit 0, 47 lines output + [read_file] read config.py from line 1 (1,200 chars) + [search_files] content search for 'compress' in agent/ -> 12 matches + """ + try: + args = json.loads(tool_args) if tool_args else {} + except (json.JSONDecodeError, TypeError): + args = {} + + content = tool_content or "" + content_len = len(content) + line_count = content.count("\n") + 1 if content.strip() else 0 + + if tool_name == "terminal": + cmd = args.get("command", "") + if len(cmd) > 80: + cmd = cmd[:77] + "..." + exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content) + exit_code = exit_match.group(1) if exit_match else "?" + return f"[terminal] ran `{cmd}` -> exit {exit_code}, {line_count} lines output" + + if tool_name == "read_file": + path = args.get("path", "?") + offset = args.get("offset", 1) + return f"[read_file] read {path} from line {offset} ({content_len:,} chars)" + + if tool_name == "write_file": + path = args.get("path", "?") + written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?" + return f"[write_file] wrote to {path} ({written_lines} lines)" + + if tool_name == "search_files": + pattern = args.get("pattern", "?") + path = args.get("path", ".") + target = args.get("target", "content") + match_count = re.search(r'"total_count"\s*:\s*(\d+)', content) + count = match_count.group(1) if match_count else "?" + return f"[search_files] {target} search for '{pattern}' in {path} -> {count} matches" + + if tool_name == "patch": + path = args.get("path", "?") + mode = args.get("mode", "replace") + return f"[patch] {mode} in {path} ({content_len:,} chars result)" + + if tool_name in ("browser_navigate", "browser_click", "browser_snapshot", + "browser_type", "browser_scroll", "browser_vision"): + url = args.get("url", "") + ref = args.get("ref", "") + detail = f" {url}" if url else (f" ref={ref}" if ref else "") + return f"[{tool_name}]{detail} ({content_len:,} chars)" + + if tool_name == "web_search": + query = args.get("query", "?") + return f"[web_search] query='{query}' ({content_len:,} chars result)" + + if tool_name == "web_extract": + urls = args.get("urls", []) + url_desc = urls[0] if isinstance(urls, list) and urls else "?" + if isinstance(urls, list) and len(urls) > 1: + url_desc += f" (+{len(urls) - 1} more)" + return f"[web_extract] {url_desc} ({content_len:,} chars)" + + if tool_name == "delegate_task": + goal = args.get("goal", "") + if len(goal) > 60: + goal = goal[:57] + "..." + return f"[delegate_task] '{goal}' ({content_len:,} chars result)" + + if tool_name == "execute_code": + code_preview = (args.get("code") or "")[:60].replace("\n", " ") + if len(args.get("code", "")) > 60: + code_preview += "..." + return f"[execute_code] `{code_preview}` ({line_count} lines output)" + + if tool_name in ("skill_view", "skills_list", "skill_manage"): + name = args.get("name", "?") + return f"[{tool_name}] name={name} ({content_len:,} chars)" + + if tool_name == "vision_analyze": + question = args.get("question", "")[:50] + return f"[vision_analyze] '{question}' ({content_len:,} chars)" + + if tool_name == "memory": + action = args.get("action", "?") + target = args.get("target", "?") + return f"[memory] {action} on {target}" + + if tool_name == "todo": + return "[todo] updated task list" + + if tool_name == "clarify": + return "[clarify] asked user a question" + + if tool_name == "text_to_speech": + return f"[text_to_speech] generated audio ({content_len:,} chars)" + + if tool_name == "cronjob": + action = args.get("action", "?") + return f"[cronjob] {action}" + + if tool_name == "process": + action = args.get("action", "?") + sid = args.get("session_id", "?") + return f"[process] {action} session={sid}" + + # Generic fallback + first_arg = "" + for k, v in list(args.items())[:2]: + sv = str(v)[:40] + first_arg += f" {k}={sv}" + return f"[{tool_name}]{first_arg} ({content_len:,} chars result)" + + class ContextCompressor(ContextEngine): """Default context engine — compresses conversation context via lossy summarization. @@ -78,6 +203,8 @@ class ContextCompressor(ContextEngine): self._context_probed = False self._context_probe_persistable = False self._previous_summary = None + self._last_compression_savings_pct = 100.0 + self._ineffective_compression_count = 0 def update_model( self, @@ -167,6 +294,9 @@ class ContextCompressor(ContextEngine): # Stores the previous compaction summary for iterative updates self._previous_summary: Optional[str] = None + # Anti-thrashing: track whether last compression was effective + self._last_compression_savings_pct: float = 100.0 + self._ineffective_compression_count: int = 0 self._summary_failure_cooldown_until: float = 0.0 def update_from_response(self, usage: Dict[str, Any]): @@ -175,9 +305,26 @@ class ContextCompressor(ContextEngine): self.last_completion_tokens = usage.get("completion_tokens", 0) def should_compress(self, prompt_tokens: int = None) -> bool: - """Check if context exceeds the compression threshold.""" + """Check if context exceeds the compression threshold. + + Includes anti-thrashing protection: if the last two compressions + each saved less than 10%, skip compression to avoid infinite loops + where each pass removes only 1-2 messages. + """ tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens - return tokens >= self.threshold_tokens + if tokens < self.threshold_tokens: + return False + # Anti-thrashing: back off if recent compressions were ineffective + if self._ineffective_compression_count >= 2: + if not self.quiet_mode: + logger.warning( + "Compression skipped — last %d compressions saved <10%% each. " + "Consider /new to start a fresh session, or /compress " + "for focused compression.", + self._ineffective_compression_count, + ) + return False + return True # ------------------------------------------------------------------ # Tool output pruning (cheap pre-pass, no LLM call) @@ -187,7 +334,16 @@ class ContextCompressor(ContextEngine): self, messages: List[Dict[str, Any]], protect_tail_count: int, protect_tail_tokens: int | None = None, ) -> tuple[List[Dict[str, Any]], int]: - """Replace old tool result contents with a short placeholder. + """Replace old tool result contents with informative 1-line summaries. + + Instead of a generic placeholder, generates a summary like:: + + [terminal] ran `npm test` -> exit 0, 47 lines output + [read_file] read config.py from line 1 (3,400 chars) + + Also deduplicates identical tool results (e.g. reading the same file + 5x keeps only the newest full copy) and truncates large tool_call + arguments in assistant messages outside the protected tail. Walks backward from the end, protecting the most recent messages that fall within ``protect_tail_tokens`` (when provided) OR the last @@ -203,6 +359,22 @@ class ContextCompressor(ContextEngine): result = [m.copy() for m in messages] pruned = 0 + # Build index: tool_call_id -> (tool_name, arguments_json) + call_id_to_tool: Dict[str, tuple] = {} + for msg in result: + if msg.get("role") == "assistant": + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + cid = tc.get("id", "") + fn = tc.get("function", {}) + call_id_to_tool[cid] = (fn.get("name", "unknown"), fn.get("arguments", "")) + else: + cid = getattr(tc, "id", "") or "" + fn = getattr(tc, "function", None) + name = getattr(fn, "name", "unknown") if fn else "unknown" + args_str = getattr(fn, "arguments", "") if fn else "" + call_id_to_tool[cid] = (name, args_str) + # Determine the prune boundary if protect_tail_tokens is not None and protect_tail_tokens > 0: # Token-budget approach: walk backward accumulating tokens @@ -211,7 +383,8 @@ class ContextCompressor(ContextEngine): min_protect = min(protect_tail_count, len(result) - 1) for i in range(len(result) - 1, -1, -1): msg = result[i] - content_len = len(msg.get("content") or "") + raw_content = msg.get("content") or "" + content_len = sum(len(p.get("text", "")) for p in raw_content) if isinstance(raw_content, list) else len(raw_content) msg_tokens = content_len // _CHARS_PER_TOKEN + 10 for tc in msg.get("tool_calls") or []: if isinstance(tc, dict): @@ -226,18 +399,69 @@ class ContextCompressor(ContextEngine): else: prune_boundary = len(result) - protect_tail_count + # Pass 1: Deduplicate identical tool results. + # When the same file is read multiple times, keep only the most recent + # full copy and replace older duplicates with a back-reference. + content_hashes: dict = {} # hash -> (index, tool_call_id) + for i in range(len(result) - 1, -1, -1): + msg = result[i] + if msg.get("role") != "tool": + continue + content = msg.get("content") or "" + # Skip multimodal content (list of content blocks) + if isinstance(content, list): + continue + if len(content) < 200: + continue + h = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:12] + if h in content_hashes: + # This is an older duplicate — replace with back-reference + result[i] = {**msg, "content": "[Duplicate tool output — same content as a more recent call]"} + pruned += 1 + else: + content_hashes[h] = (i, msg.get("tool_call_id", "?")) + + # Pass 2: Replace old tool results with informative summaries for i in range(prune_boundary): msg = result[i] if msg.get("role") != "tool": continue content = msg.get("content", "") + # Skip multimodal content (list of content blocks) + if isinstance(content, list): + continue if not content or content == _PRUNED_TOOL_PLACEHOLDER: continue + # Skip already-deduplicated or previously-summarized results + if content.startswith("[Duplicate tool output"): + continue # Only prune if the content is substantial (>200 chars) if len(content) > 200: - result[i] = {**msg, "content": _PRUNED_TOOL_PLACEHOLDER} + call_id = msg.get("tool_call_id", "") + tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", "")) + summary = _summarize_tool_result(tool_name, tool_args, content) + result[i] = {**msg, "content": summary} pruned += 1 + # Pass 3: Truncate large tool_call arguments in assistant messages + # outside the protected tail. write_file with 50KB content, for + # example, survives pruning entirely without this. + for i in range(prune_boundary): + msg = result[i] + if msg.get("role") != "assistant" or not msg.get("tool_calls"): + continue + new_tcs = [] + modified = False + for tc in msg["tool_calls"]: + if isinstance(tc, dict): + args = tc.get("function", {}).get("arguments", "") + if len(args) > 500: + tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}} + modified = True + new_tcs.append(tc) + if modified: + result[i] = {**msg, "tool_calls": new_tcs} + return result, pruned # ------------------------------------------------------------------ @@ -357,29 +581,37 @@ class ContextCompressor(ContextEngine): ) # Shared structured template (used by both paths). - # Key changes vs v1: - # - "Pending User Asks" section (from Claude Code) explicitly tracks - # unanswered questions so the model knows what's resolved vs open - # - "Remaining Work" replaces "Next Steps" to avoid reading as active - # instructions - # - "Resolved Questions" makes it clear which questions were already - # answered (prevents model from re-answering them) _template_sections = f"""## Goal [What the user is trying to accomplish] ## Constraints & Preferences [User preferences, coding style, constraints, important decisions] -## Progress -### Done -[Completed work — include specific file paths, commands run, results obtained] -### In Progress -[Work currently underway] -### Blocked -[Any blockers or issues encountered] +## Completed Actions +[Numbered list of concrete actions taken — include tool used, target, and outcome. +Format each as: N. ACTION target — outcome [tool: name] +Example: +1. READ config.py:45 — found `==` should be `!=` [tool: read_file] +2. PATCH config.py:45 — changed `==` to `!=` [tool: patch] +3. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal] +Be specific with file paths, commands, line numbers, and results.] + +## Active State +[Current working state — include: +- Working directory and branch (if applicable) +- Modified/created files with brief note on each +- Test status (X/Y passing) +- Any running processes or servers +- Environment details that matter] + +## In Progress +[Work currently underway — what was being done when compaction fired] + +## Blocked +[Any blockers, errors, or issues not yet resolved. Include exact error messages.] ## Key Decisions -[Important technical decisions and why they were made] +[Important technical decisions and WHY they were made] ## Resolved Questions [Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them] @@ -396,10 +628,7 @@ class ContextCompressor(ContextEngine): ## Critical Context [Any specific values, error messages, configuration details, or data that would be lost without explicit preservation] -## Tools & Patterns -[Which tools were used, how they were used effectively, and any tool-specific discoveries] - -Target ~{summary_budget} tokens. Be specific — include file paths, command outputs, error messages, and concrete values rather than vague descriptions. +Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. Write only the summary body. Do not include any preamble or prefix.""" @@ -415,7 +644,7 @@ PREVIOUS SUMMARY: NEW TURNS TO INCORPORATE: {content_to_summarize} -Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Move answered questions to "Resolved Questions". Remove information only if it is clearly obsolete. +Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. {_template_sections}""" else: @@ -450,7 +679,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio "api_mode": self.api_mode, }, "messages": [{"role": "user", "content": prompt}], - "max_tokens": summary_budget * 2, + "max_tokens": int(summary_budget * 1.3), # timeout resolved from auxiliary.compression.timeout config by call_llm } if self.summary_model: @@ -466,6 +695,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio self._summary_failure_cooldown_until = 0.0 return self._with_summary_prefix(summary) except RuntimeError: + # No provider configured — long cooldown, unlikely to self-resolve self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS logging.warning("Context compression: no provider available for " "summary. Middle turns will be dropped without summary " @@ -473,12 +703,14 @@ The user has requested that this compaction PRIORITISE preserving all informatio _SUMMARY_FAILURE_COOLDOWN_SECONDS) return None except Exception as e: - self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS + # Transient errors (timeout, rate limit, network) — shorter cooldown + _transient_cooldown = 60 + self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown logging.warning( "Failed to generate context summary: %s. " "Further summary attempts paused for %d seconds.", e, - _SUMMARY_FAILURE_COOLDOWN_SECONDS, + _transient_cooldown, ) return None @@ -744,11 +976,11 @@ The user has requested that this compaction PRIORITISE preserving all informatio compressed = [] for i in range(compress_start): msg = messages[i].copy() - if i == 0 and msg.get("role") == "system" and self.compression_count == 0: - msg["content"] = ( - (msg.get("content") or "") - + "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]" - ) + if i == 0 and msg.get("role") == "system": + existing = msg.get("content") or "" + _compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]" + if _compression_note not in existing: + msg["content"] = existing + "\n\n" + _compression_note compressed.append(msg) # If LLM summary failed, insert a static fallback so the model @@ -806,14 +1038,24 @@ The user has requested that this compaction PRIORITISE preserving all informatio compressed = self._sanitize_tool_pairs(compressed) + new_estimate = estimate_messages_tokens_rough(compressed) + saved_estimate = display_tokens - new_estimate + + # Anti-thrashing: track compression effectiveness + savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0 + self._last_compression_savings_pct = savings_pct + if savings_pct < 10: + self._ineffective_compression_count += 1 + else: + self._ineffective_compression_count = 0 + if not self.quiet_mode: - new_estimate = estimate_messages_tokens_rough(compressed) - saved_estimate = display_tokens - new_estimate logger.info( - "Compressed: %d -> %d messages (~%d tokens saved)", + "Compressed: %d -> %d messages (~%d tokens saved, %.0f%%)", n_messages, len(compressed), saved_estimate, + savings_pct, ) logger.info("Compression #%d complete", self.compression_count) From 5d5d21556e129ecab93b38bfe0a5b776249cf5f0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:37:45 -0700 Subject: [PATCH 151/849] fix: sync client.api_key during UnicodeEncodeError ASCII recovery (#10090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing recovery block sanitized self.api_key and self._client_kwargs['api_key'] but did not update self.client.api_key. The OpenAI SDK stores its own copy of api_key and reads it dynamically via the auth_headers property on every request. Without this fix, the retry after sanitization would still send the corrupted key in the Authorization header, causing the same UnicodeEncodeError. The bug manifests when an API key contains Unicode lookalike characters (e.g. ʋ U+028B instead of v) from copy-pasting out of PDFs, rich-text editors, or web pages with decorative fonts. httpx hard-encodes all HTTP headers as ASCII, so the non-ASCII char in the Authorization header triggers the error. Adds TestApiKeyClientSync with two tests verifying: - All three key locations are synced after sanitization - Recovery handles client=None (pre-init) without crashing --- run_agent.py | 5 ++ tests/run_agent/test_unicode_ascii_codec.py | 64 +++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/run_agent.py b/run_agent.py index 55b4efaa6..48382389e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9037,6 +9037,11 @@ class AIAgent: self.api_key = _clean_key if isinstance(getattr(self, "_client_kwargs", None), dict): self._client_kwargs["api_key"] = _clean_key + # Also update the live client — it holds its + # own copy of api_key which auth_headers reads + # dynamically on every request. + if getattr(self, "client", None) is not None and hasattr(self.client, "api_key"): + self.client.api_key = _clean_key _credential_sanitized = True self._vprint( f"{self.log_prefix}⚠️ API key contained non-ASCII characters " diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index ef4f3f339..a8a52c34a 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -230,3 +230,67 @@ class TestSanitizeStructureNonAscii: assert _sanitize_structure_non_ascii(payload) is True assert payload["default_headers"]["X-Title"] == "Hermes Agent" assert payload["default_headers"]["User-Agent"] == "Hermes/1.0 " + + +class TestApiKeyClientSync: + """Verify that ASCII recovery updates the live OpenAI client's api_key. + + The OpenAI SDK stores its own copy of api_key which auth_headers reads + dynamically. If only self.api_key is updated but self.client.api_key + is not, the next request still sends the corrupted key in the + Authorization header. + """ + + def test_client_api_key_updated_on_sanitize(self): + """Simulate the recovery path and verify client.api_key is synced.""" + from unittest.mock import MagicMock + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-abc\u028bdef" # ʋ lookalike at position 11 + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.quiet_mode = True + + # Mock client with its own api_key attribute (like the real OpenAI client) + mock_client = MagicMock() + mock_client.api_key = bad_key + agent.client = mock_client + + # --- replicate the recovery logic from run_agent.py --- + _raw_key = agent.api_key + _clean_key = _strip_non_ascii(_raw_key) + assert _clean_key != _raw_key, "test precondition: key should have non-ASCII" + + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + # All three locations should now hold the clean key + assert agent.api_key == "sk-proj-abcdef" + assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" + assert agent.client.api_key == "sk-proj-abcdef" + # The bad char should be gone from all of them + assert "\u028b" not in agent.api_key + assert "\u028b" not in agent._client_kwargs["api_key"] + assert "\u028b" not in agent.client.api_key + + def test_client_none_does_not_crash(self): + """Recovery should not crash when client is None (pre-init).""" + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-\u028b" + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.client = None + + _clean_key = _strip_non_ascii(bad_key) + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + assert agent.api_key == "sk-proj-" + assert agent.client is None # should not have been touched From 772cfb6c4ec7770759b4e0c8552b934fe9ff897d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:38:17 -0700 Subject: [PATCH 152/849] fix: stale agent timeout, uv venv detection, empty response after tools, compression model fallback (#9051, #8620, #9400) (#10093) Four independent fixes: 1. Reset activity timestamp on cached agent reuse (#9051) When the gateway reuses a cached AIAgent for a new turn, the _last_activity_ts from the previous turn (possibly hours ago) carried over. The inactivity timeout handler immediately saw the agent as idle for hours and killed it. Fix: reset _last_activity_ts, _last_activity_desc, and _api_call_count when retrieving an agent from the cache. 2. Detect uv-managed virtual environments (#8620 sub-issue 1) The systemd unit generator fell back to sys.executable (uv's standalone Python) when running under 'uv run', because sys.prefix == sys.base_prefix. The generated ExecStart pointed to a Python binary without site-packages. Fix: check VIRTUAL_ENV env var before falling back to sys.executable. uv sets VIRTUAL_ENV even when sys.prefix doesn't reflect the venv. 3. Nudge model to continue after empty post-tool response (#9400) Weaker models sometimes return empty after tool calls. The agent silently abandoned the remaining work. Fix: append assistant('(empty)') + user nudge message and retry once. Resets after each successful tool round. 4. Compression model fallback on permanent errors (#8620 sub-issue 4) When the default summary model (gemini-3-flash) returns 503 'model_not_found' on custom proxies, the compressor entered a 600s cooldown, leaving context growing unbounded. Fix: detect permanent model-not-found errors (503, 404, 'model_not_found', 'no available channel') and fall back to using the main model for compression instead of entering cooldown. One-time fallback with immediate retry. Test plan: 40 compressor tests + 97 gateway/CLI tests + 9 venv tests pass --- agent/context_compressor.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 74bbbd5d0..ac5db7762 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -693,6 +693,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Store for iterative updates on next compaction self._previous_summary = summary self._summary_failure_cooldown_until = 0.0 + self._summary_model_fallen_back = False return self._with_summary_prefix(summary) except RuntimeError: # No provider configured — long cooldown, unlikely to self-resolve @@ -703,6 +704,34 @@ The user has requested that this compaction PRIORITISE preserving all informatio _SUMMARY_FAILURE_COOLDOWN_SECONDS) return None except Exception as e: + # If the summary model is different from the main model and the + # error looks permanent (model not found, 503, 404), fall back to + # using the main model instead of entering cooldown that leaves + # context growing unbounded. (#8620 sub-issue 4) + _status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None) + _err_str = str(e).lower() + _is_model_not_found = ( + _status in (404, 503) + or "model_not_found" in _err_str + or "does not exist" in _err_str + or "no available channel" in _err_str + ) + if ( + _is_model_not_found + and self.summary_model + and self.summary_model != self.model + and not getattr(self, "_summary_model_fallen_back", False) + ): + self._summary_model_fallen_back = True + logging.warning( + "Summary model '%s' not available (%s). " + "Falling back to main model '%s' for compression.", + self.summary_model, e, self.model, + ) + self.summary_model = "" # empty = use main model + self._summary_failure_cooldown_until = 0.0 # no cooldown + return self._generate_summary(messages, summary_budget) # retry immediately + # Transient errors (timeout, rate limit, network) — shorter cooldown _transient_cooldown = 60 self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown From 029938fbed2e74ea818cd65df454219c91370c60 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:13:02 -0700 Subject: [PATCH 153/849] fix(cli): defensive subparser routing for argparse bpo-9338 (#10113) On some Python versions, argparse fails to route subcommand tokens when the parent parser has nargs='?' optional arguments (--continue). The symptom: 'hermes model' produces 'unrecognized arguments: model' even though 'model' is a registered subcommand. Fix: when argv contains a token matching a known subcommand, set subparsers.required=True to force deterministic routing. If that fails (e.g. 'hermes -c model' where 'model' is consumed as the session name for --continue), fall back to the default optional-subparsers behaviour. Adds 13 tests covering all key argument combinations. Reported via user screenshot showing the exact error on an installed version with the model subcommand listed in usage but rejected at parse time. --- hermes_cli/main.py | 32 +++- .../test_subparser_routing_fallback.py | 148 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_subparser_routing_fallback.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 017280184..b45c9abb8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6046,7 +6046,37 @@ Examples: sys.exit(1) _processed_argv = _coalesce_session_name_args(sys.argv[1:]) - args = parser.parse_args(_processed_argv) + + # ── Defensive subparser routing (bpo-9338 workaround) ─────────── + # On some Python versions (notably <3.11), argparse fails to route + # subcommand tokens when the parent parser has nargs='?' optional + # arguments (--continue). The symptom: "unrecognized arguments: model" + # even though 'model' is a registered subcommand. + # + # Fix: when argv contains a token matching a known subcommand, set + # subparsers.required=True to force deterministic routing. If that + # 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("-")) + + if _has_cmd_token: + subparsers.required = True + _saved_stderr = sys.stderr + try: + sys.stderr = _io.StringIO() + args = parser.parse_args(_processed_argv) + sys.stderr = _saved_stderr + except SystemExit: + sys.stderr = _saved_stderr + # Subcommand name was consumed as a flag value (e.g. -c model). + # Fall back to optional subparsers so argparse handles it normally. + subparsers.required = False + args = parser.parse_args(_processed_argv) + else: + subparsers.required = False + args = parser.parse_args(_processed_argv) # Handle --version flag if args.version: diff --git a/tests/hermes_cli/test_subparser_routing_fallback.py b/tests/hermes_cli/test_subparser_routing_fallback.py new file mode 100644 index 000000000..ba907ca12 --- /dev/null +++ b/tests/hermes_cli/test_subparser_routing_fallback.py @@ -0,0 +1,148 @@ +"""Tests for the defensive subparser routing workaround (bpo-9338). + +The main() function in hermes_cli/main.py sets subparsers.required=True +when argv contains a known subcommand name. This forces deterministic +routing on Python versions where argparse fails to match subcommand tokens +when the parent parser has nargs='?' optional arguments (--continue). + +If the subcommand token is consumed as a flag value (e.g. `hermes -c model` +to resume a session named 'model'), the required=True parse raises +SystemExit and the code falls back to the default required=False behaviour. +""" +import argparse +import io +import sys + +import pytest + + +def _build_parser(): + """Build a minimal replica of the hermes top-level parser.""" + parser = argparse.ArgumentParser(prog="hermes") + parser.add_argument("--version", "-V", action="store_true") + parser.add_argument("--resume", "-r", metavar="SESSION", default=None) + parser.add_argument( + "--continue", "-c", + dest="continue_last", + nargs="?", + const=True, + default=None, + metavar="SESSION_NAME", + ) + parser.add_argument("--worktree", "-w", action="store_true", default=False) + parser.add_argument("--skills", "-s", action="append", default=None) + parser.add_argument("--yolo", action="store_true", default=False) + parser.add_argument("--pass-session-id", action="store_true", default=False) + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + chat_p = subparsers.add_parser("chat") + chat_p.add_argument("-q", "--query", default=None) + subparsers.add_parser("model") + subparsers.add_parser("gateway") + subparsers.add_parser("setup") + return parser, subparsers + + +def _safe_parse(parser, subparsers, argv): + """Replica of the defensive parsing logic from main().""" + known_cmds = set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set() + has_cmd_token = any(t in known_cmds for t in argv if not t.startswith("-")) + + if has_cmd_token: + subparsers.required = True + saved_stderr = sys.stderr + try: + sys.stderr = io.StringIO() + args = parser.parse_args(argv) + sys.stderr = saved_stderr + return args + except SystemExit: + sys.stderr = saved_stderr + subparsers.required = False + return parser.parse_args(argv) + else: + subparsers.required = False + return parser.parse_args(argv) + + +class TestSubparserRoutingFallback: + """Verify the bpo-9338 defensive routing works for all key cases.""" + + def test_direct_subcommand(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["model"]) + assert args.command == "model" + + def test_subcommand_with_flags(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["--yolo", "model"]) + assert args.command == "model" + assert args.yolo is True + + def test_bare_hermes_defaults_to_none(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, []) + assert args.command is None + + def test_flags_only_defaults_to_none(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["--yolo"]) + assert args.command is None + assert args.yolo is True + + def test_continue_flag_alone(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-c"]) + assert args.command is None + assert args.continue_last is True + + def test_continue_with_session_name(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-c", "myproject"]) + assert args.command is None + assert args.continue_last == "myproject" + + def test_continue_with_subcommand_name_as_session(self): + """Edge case: session named 'model' — should be treated as session name, not subcommand.""" + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-c", "model"]) + assert args.command is None + assert args.continue_last == "model" + + def test_continue_with_session_then_subcommand(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-c", "myproject", "model"]) + assert args.command == "model" + assert args.continue_last == "myproject" + + def test_chat_with_query(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["chat", "-q", "hello"]) + assert args.command == "chat" + assert args.query == "hello" + + def test_resume_flag(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-r", "abc123"]) + assert args.command is None + assert args.resume == "abc123" + + def test_resume_with_subcommand(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-r", "abc123", "chat"]) + assert args.command == "chat" + assert args.resume == "abc123" + + def test_skills_flag_with_subcommand(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["-s", "myskill", "chat"]) + assert args.command == "chat" + assert args.skills == ["myskill"] + + def test_all_flags_with_subcommand(self): + parser, sub = _build_parser() + args = _safe_parse(parser, sub, ["--yolo", "-w", "-s", "myskill", "model"]) + assert args.command == "model" + assert args.yolo is True + assert args.worktree is True + assert args.skills == ["myskill"] From 9932366f3cac1b85eb1dd8a70ca32fffdc973512 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 23:09:44 -0700 Subject: [PATCH 154/849] feat(doctor): add Command Installation check for hermes bin symlink hermes doctor now checks whether the ~/.local/bin/hermes symlink exists and points to the correct venv entry point. With --fix, it creates or repairs the symlink automatically. Covers: - Missing symlink at ~/.local/bin/hermes (or $PREFIX/bin on Termux) - Symlink pointing to wrong target - Missing venv entry point (venv/bin/hermes or .venv/bin/hermes) - PATH warning when ~/.local/bin is not on PATH - Skipped on Windows (different mechanism) Addresses user report: 'python -m hermes_cli.main doesn't have an option to fix the local bin/install' 10 new tests covering all scenarios. --- hermes_cli/doctor.py | 83 +++++- .../hermes_cli/test_doctor_command_install.py | 275 ++++++++++++++++++ 2 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_doctor_command_install.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 892ff0021..b89a80409 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -8,6 +8,7 @@ import os import sys import subprocess import shutil +from pathlib import Path from hermes_cli.config import get_project_root, get_hermes_home, get_env_path from hermes_constants import display_hermes_home @@ -513,7 +514,87 @@ def run_doctor(args): pass _check_gateway_service_linger(issues) - + + # ========================================================================= + # Check: Command installation (hermes bin symlink) + # ========================================================================= + if sys.platform != "win32": + print() + print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD)) + + # Determine the venv entry point location + _venv_bin = None + for _venv_name in ("venv", ".venv"): + _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes" + if _candidate.exists(): + _venv_bin = _candidate + break + + # Determine the expected command link directory (mirrors install.sh logic) + _prefix = os.environ.get("PREFIX", "") + _is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix + if _is_termux_env and _prefix: + _cmd_link_dir = Path(_prefix) / "bin" + _cmd_link_display = "$PREFIX/bin" + else: + _cmd_link_dir = Path.home() / ".local" / "bin" + _cmd_link_display = "~/.local/bin" + _cmd_link = _cmd_link_dir / "hermes" + + if _venv_bin is None: + check_warn( + "Venv entry point not found", + "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')" + ) + manual_issues.append( + f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" + ) + else: + check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})") + + # Check the symlink at the command link location + if _cmd_link.is_symlink(): + _target = _cmd_link.resolve() + _expected = _venv_bin.resolve() + if _target == _expected: + check_ok(f"{_cmd_link_display}/hermes → correct target") + else: + check_warn( + f"{_cmd_link_display}/hermes points to wrong target", + f"(→ {_target}, expected → {_expected})" + ) + if should_fix: + _cmd_link.unlink() + _cmd_link.symlink_to(_venv_bin) + check_ok(f"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}") + fixed_count += 1 + else: + issues.append(f"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'") + elif _cmd_link.exists(): + # It's a regular file, not a symlink — possibly a wrapper script + check_ok(f"{_cmd_link_display}/hermes exists (non-symlink)") + else: + check_fail( + f"{_cmd_link_display}/hermes not found", + "(hermes command may not work outside the venv)" + ) + if should_fix: + _cmd_link_dir.mkdir(parents=True, exist_ok=True) + _cmd_link.symlink_to(_venv_bin) + check_ok(f"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}") + fixed_count += 1 + + # Check if the link dir is on PATH + _path_dirs = os.environ.get("PATH", "").split(os.pathsep) + if str(_cmd_link_dir) not in _path_dirs: + check_warn( + f"{_cmd_link_display} is not on your PATH", + "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")" + ) + manual_issues.append(f"Add {_cmd_link_display} to your PATH") + else: + issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") + # ========================================================================= # Check: External tools # ========================================================================= diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py new file mode 100644 index 000000000..8b046b9c2 --- /dev/null +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -0,0 +1,275 @@ +"""Tests for the Command Installation check in hermes doctor.""" + +import os +import sys +import types +from argparse import Namespace +from pathlib import Path + +import pytest + +import hermes_cli.doctor as doctor_mod + + +def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"): + """Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + # Create a fake venv entry point + venv_bin_dir = project / venv_name / "bin" + venv_bin_dir.mkdir(parents=True, exist_ok=True) + hermes_bin = venv_bin_dir / "hermes" + hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n") + hermes_bin.chmod(0o755) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + + # Stub model_tools so doctor doesn't fail on import + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + # Stub auth checks + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + + # Stub httpx.get to avoid network calls + try: + import httpx + monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200)) + except Exception: + pass + + return home, project, hermes_bin + + +def _run_doctor(fix=False): + """Run doctor and capture stdout.""" + import io + import contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=fix)) + return buf.getvalue() + + +class TestDoctorCommandInstallation: + """Tests for the ◆ Command Installation section.""" + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_correct_symlink_shows_ok(self, monkeypatch, tmp_path): + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + # Create the command link dir with correct symlink + cmd_link_dir = tmp_path / ".local" / "bin" + cmd_link_dir.mkdir(parents=True) + cmd_link = cmd_link_dir / "hermes" + cmd_link.symlink_to(hermes_bin) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "Venv entry point exists" in out + assert "correct target" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_missing_symlink_shows_fail(self, monkeypatch, tmp_path): + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Don't create the symlink — it should be missing + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "Venv entry point exists" in out + assert "not found" in out + assert "hermes doctor --fix" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_fix_creates_missing_symlink(self, monkeypatch, tmp_path): + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=True) + assert "Command Installation" in out + assert "Created symlink" in out + + # Verify the symlink was actually created + cmd_link = tmp_path / ".local" / "bin" / "hermes" + assert cmd_link.is_symlink() + assert cmd_link.resolve() == hermes_bin.resolve() + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_wrong_target_symlink_shows_warn(self, monkeypatch, tmp_path): + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + # Create a symlink pointing to the wrong target + cmd_link_dir = tmp_path / ".local" / "bin" + cmd_link_dir.mkdir(parents=True) + cmd_link = cmd_link_dir / "hermes" + wrong_target = tmp_path / "wrong_hermes" + wrong_target.write_text("#!/usr/bin/env python\n") + cmd_link.symlink_to(wrong_target) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "wrong target" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path): + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + # Create a symlink pointing to wrong target + cmd_link_dir = tmp_path / ".local" / "bin" + cmd_link_dir.mkdir(parents=True) + cmd_link = cmd_link_dir / "hermes" + wrong_target = tmp_path / "wrong_hermes" + wrong_target.write_text("#!/usr/bin/env python\n") + cmd_link.symlink_to(wrong_target) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=True) + assert "Fixed symlink" in out + + # Verify the symlink now points to the correct target + assert cmd_link.is_symlink() + assert cmd_link.resolve() == hermes_bin.resolve() + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + + project = tmp_path / "project" + project.mkdir(exist_ok=True) + # Do NOT create any venv entry point + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + try: + import httpx + monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200)) + except Exception: + pass + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "Venv entry point not found" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path): + """The check finds entry points in .venv/ as well as venv/.""" + home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv") + + # Create the command link with correct symlink + hermes_bin = project / ".venv" / "bin" / "hermes" + cmd_link_dir = tmp_path / ".local" / "bin" + cmd_link_dir.mkdir(parents=True) + cmd_link = cmd_link_dir / "hermes" + cmd_link.symlink_to(hermes_bin) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + assert "Venv entry point exists" in out + assert ".venv/bin/hermes" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_non_symlink_regular_file_shows_ok(self, monkeypatch, tmp_path): + """If ~/.local/bin/hermes is a regular file (not symlink), accept it.""" + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + cmd_link_dir = tmp_path / ".local" / "bin" + cmd_link_dir.mkdir(parents=True) + cmd_link = cmd_link_dir / "hermes" + cmd_link.write_text("#!/bin/sh\nexec python -m hermes_cli.main \"$@\"\n") + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + assert "non-symlink" in out + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path): + """On Termux, the command link dir is $PREFIX/bin.""" + prefix_dir = tmp_path / "termux_prefix" + prefix_bin = prefix_dir / "bin" + prefix_bin.mkdir(parents=True) + + home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path) + + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", str(prefix_dir)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "$PREFIX/bin" in out + + def test_windows_skips_check(self, monkeypatch, tmp_path): + """On Windows, the Command Installation section is skipped.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setattr(sys, "platform", "win32") + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + try: + import httpx + monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200)) + except Exception: + pass + + out = _run_doctor(fix=False) + assert "Command Installation" not in out From da8bab77fb762bc554f45da3cc2eb3a823983d4b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 13 Apr 2026 10:32:31 +0000 Subject: [PATCH 155/849] fix(cli): restore messaging toolset for gateway platforms --- hermes_cli/tools_config.py | 1 + tests/hermes_cli/test_tools_config.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b518c001e..5fe8cdc79 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -63,6 +63,7 @@ CONFIGURABLE_TOOLSETS = [ ("clarify", "❓ Clarifying Questions", "clarify"), ("delegation", "👥 Task Delegation", "delegate_task"), ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), + ("messaging", "📨 Cross-Platform Messaging", "send_message"), ("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("homeassistant", "🏠 Home Assistant", "smart home device control"), ] diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ed79559d2..3ad0be886 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -8,6 +8,7 @@ from hermes_cli.tools_config import ( _platform_toolset_summary, _save_platform_tools, _toolset_has_keys, + CONFIGURABLE_TOOLSETS, TOOL_CATEGORIES, _visible_providers, tools_command, @@ -22,6 +23,15 @@ def test_get_platform_tools_uses_default_when_platform_not_configured(): assert enabled +def test_configurable_toolsets_include_messaging(): + assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) + +def test_get_platform_tools_default_telegram_includes_messaging(): + enabled = _get_platform_tools({}, "telegram") + + assert "messaging" in enabled + + def test_get_platform_tools_preserves_explicit_empty_selection(): config = {"platform_toolsets": {"cli": []}} From df7be3d8aef682e1cd03e028548fbad7a2d132a2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:07:50 -0700 Subject: [PATCH 156/849] fix(cli): /model picker shows curated models instead of full catalog (#10146) The /model picker called provider_model_ids() which fetches the FULL live API catalog (hundreds of models for Anthropic, Copilot, etc.) and only fell back to the curated list when the live fetch failed. This flips the priority: use the curated model list from list_authenticated_providers() (same lists as `hermes model` and gateway pickers), falling back to provider_model_ids() only when the curated list is empty (e.g. user-defined endpoints). --- cli.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/cli.py b/cli.py index b8f34c9bb..0ed2d3232 100644 --- a/cli.py +++ b/cli.py @@ -4588,16 +4588,19 @@ class HermesCLI: self._close_model_picker() return provider_data = providers[selected] - model_list = [] - try: - from hermes_cli.models import provider_model_ids - live = provider_model_ids(provider_data["slug"]) - if live: - model_list = live - except Exception: - pass + # Use the curated model list from list_authenticated_providers() + # (same lists as `hermes model` and gateway pickers). + # Only fall back to the live provider catalog when the curated + # list is empty (e.g. user-defined endpoints with no curated list). + model_list = provider_data.get("models", []) if not model_list: - model_list = provider_data.get("models", []) + try: + from hermes_cli.models import provider_model_ids + live = provider_model_ids(provider_data["slug"]) + if live: + model_list = live + except Exception: + pass state["stage"] = "model" state["provider_data"] = provider_data state["model_list"] = model_list From 03446e06bbfe60bff074d1bbb5336bdd6849ddc3 Mon Sep 17 00:00:00 2001 From: bkadish <146666892+bkadish@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:15:15 -0700 Subject: [PATCH 157/849] fix(send_message): accept Matrix room IDs and user MXIDs as explicit targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_parse_target_ref` has explicit-reference branches for Telegram, Feishu, and numeric IDs, but none for Matrix. As a result, callers of `send_message(target="matrix:!roomid:server")` or `send_message(target="matrix:@user:server")` fall through to `(None, None, False)` and the tool errors out with a resolution failure — even though a raw Matrix room ID or MXID is the most unambiguous possible target. Three-line fix: recognize `!…` as a room ID and `@…` as a user MXID when platform is `matrix`, and return them as explicit targets. Alias-based targets (`#…`) continue to go through the normal resolve path. --- tools/send_message_tool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 391e03baa..f99bcdaf4 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -248,6 +248,9 @@ def _parse_target_ref(platform_name: str, target_ref: str): return match.group(1), None, True if target_ref.lstrip("-").isdigit(): return target_ref, None, True + # Matrix room IDs (start with !) and user IDs (start with @) are explicit + if platform_name == "matrix" and (target_ref.startswith("!") or target_ref.startswith("@")): + return target_ref, None, True return None, None, False From 180b14442f88003cabb11f7183e2bb57e81f5f1e Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 23:15:19 -0700 Subject: [PATCH 158/849] test: add _parse_target_ref Matrix coverage for salvaged PR #6144 --- tests/tools/test_send_message_tool.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index d6f07e2e6..b9d99e2b3 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -752,6 +752,38 @@ class TestParseTargetRefDiscord: assert is_explicit is True +class TestParseTargetRefMatrix: + """_parse_target_ref correctly handles Matrix room IDs and user MXIDs.""" + + def test_matrix_room_id_is_explicit(self): + """Matrix room IDs (!) are recognized as explicit targets.""" + chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org") + assert chat_id == "!HLOQwxYGgFPMPJUSNR:matrix.org" + assert thread_id is None + assert is_explicit is True + + def test_matrix_user_mxid_is_explicit(self): + """Matrix user MXIDs (@) are recognized as explicit targets.""" + chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "@hermes:matrix.org") + assert chat_id == "@hermes:matrix.org" + assert thread_id is None + assert is_explicit is True + + def test_matrix_alias_is_not_explicit(self): + """Matrix room aliases (#) are NOT explicit — they need resolution.""" + chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "#general:matrix.org") + assert chat_id is None + assert is_explicit is False + + def test_matrix_prefix_only_matches_matrix_platform(self): + """! and @ prefixes are only treated as explicit for the matrix platform.""" + chat_id, _, is_explicit = _parse_target_ref("telegram", "!something") + assert is_explicit is False + + chat_id, _, is_explicit = _parse_target_ref("discord", "@someone") + assert is_explicit is False + + class TestSendDiscordThreadId: """_send_discord uses thread_id when provided.""" From e69526be799edcfa02c25bf8966af2d8cce65ee3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:10:59 -0700 Subject: [PATCH 159/849] fix(send_message): URL-encode Matrix room IDs and add Matrix to schema examples (#10151) Matrix room IDs contain ! and : which must be percent-encoded in URI path segments per the Matrix C-S spec. Without encoding, some homeservers reject the PUT request. Also adds 'matrix:!roomid:server.org' and 'matrix:@user:server.org' to the tool schema examples so models know the correct target format. --- tests/tools/test_send_message_tool.py | 36 +++++++++++++++++++++++++++ tools/send_message_tool.py | 6 +++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index b9d99e2b3..9a277d598 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -886,3 +886,39 @@ class TestSendToPlatformDiscordThread: send_mock.assert_awaited_once() _, call_kwargs = send_mock.await_args assert call_kwargs["thread_id"] is None + + +class TestSendMatrixUrlEncoding: + """_send_matrix URL-encodes Matrix room IDs in the API path.""" + + def test_room_id_is_percent_encoded_in_url(self): + """Matrix room IDs with ! and : are percent-encoded in the PUT URL.""" + import aiohttp + + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"event_id": "$evt123"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.put = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + from tools.send_message_tool import _send_matrix + result = asyncio.get_event_loop().run_until_complete( + _send_matrix( + "test_token", + {"homeserver": "https://matrix.example.org"}, + "!HLOQwxYGgFPMPJUSNR:matrix.org", + "hello", + ) + ) + + assert result["success"] is True + # Verify the URL was called with percent-encoded room ID + put_url = mock_session.put.call_args[0][0] + assert "%21HLOQwxYGgFPMPJUSNR%3Amatrix.org" in put_url + assert "!HLOQwxYGgFPMPJUSNR:matrix.org" not in put_url diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index f99bcdaf4..6c9632b2c 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -68,7 +68,7 @@ SEND_MESSAGE_SCHEMA = { }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567'" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org'" }, "message": { "type": "string", @@ -819,7 +819,9 @@ async def _send_matrix(token, extra, chat_id, message): if not homeserver or not token: return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"} txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}" - url = f"{homeserver}/_matrix/client/v3/rooms/{chat_id}/send/m.room.message/{txn_id}" + from urllib.parse import quote + encoded_room = quote(chat_id, safe="") + url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} # Build message payload with optional HTML formatted_body. From a4e1842f1217983f05fd40f544f79b8a785324b4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:19:43 -0700 Subject: [PATCH 160/849] fix: strip reasoning item IDs from Responses API input when store=False (#10217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With store=False (our default for the Responses API), the API does not persist response items. When reasoning items with 'id' fields were replayed on subsequent turns, the API attempted a server-side lookup for those IDs and returned 404: Item with id 'rs_...' not found. Items are not persisted when store is set to false. The encrypted_content blob is self-contained for reasoning chain continuity — the id field is unnecessary and triggers the failed lookup. Fix: strip 'id' from reasoning items in both _chat_messages_to_responses_input (message conversion) and _preflight_codex_input_items (normalization layer). The id is still used for local deduplication but never sent to the API. Reported by @zuogl448 on GPT-5.4. --- run_agent.py | 13 +++++++-- .../test_run_agent_codex_responses.py | 28 ++++++++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/run_agent.py b/run_agent.py index 48382389e..efaeba829 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3589,7 +3589,12 @@ class AIAgent: item_id = ri.get("id") if item_id and item_id in seen_item_ids: continue - items.append(ri) + # Strip the "id" field — with store=False the + # Responses API cannot look up items by ID and + # returns 404. The encrypted_content blob is + # self-contained for reasoning chain continuity. + replay_item = {k: v for k, v in ri.items() if k != "id"} + items.append(replay_item) if item_id: seen_item_ids.add(item_id) has_codex_reasoning = True @@ -3730,8 +3735,10 @@ class AIAgent: continue seen_ids.add(item_id) reasoning_item = {"type": "reasoning", "encrypted_content": encrypted} - if isinstance(item_id, str) and item_id: - reasoning_item["id"] = item_id + # Do NOT include the "id" in the outgoing item — with + # store=False (our default) the API tries to resolve the + # id server-side and returns 404. The id is still used + # above for local deduplication via seen_ids. summary = item.get("summary") if isinstance(summary, list): reasoning_item["summary"] = summary diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 785d85886..2b2295565 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1249,13 +1249,17 @@ def test_chat_messages_to_responses_input_deduplicates_reasoning_ids(monkeypatch ] items = agent._chat_messages_to_responses_input(messages) - reasoning_ids = [it["id"] for it in items if it.get("type") == "reasoning"] - # rs_aaa should appear only once (first occurrence kept) - assert reasoning_ids.count("rs_aaa") == 1 - # rs_bbb and rs_ccc should each appear once - assert reasoning_ids.count("rs_bbb") == 1 - assert reasoning_ids.count("rs_ccc") == 1 - assert len(reasoning_ids) == 3 + reasoning_items = [it for it in items if it.get("type") == "reasoning"] + # Dedup: rs_aaa appears in both turns but should only be emitted once. + # 3 unique items total: enc_1 (from rs_aaa), enc_2 (rs_bbb), enc_3 (rs_ccc). + assert len(reasoning_items) == 3 + encrypted = [it["encrypted_content"] for it in reasoning_items] + assert encrypted.count("enc_1") == 1 + assert "enc_2" in encrypted + assert "enc_3" in encrypted + # IDs must be stripped — with store=False the API 404s on id lookups. + for it in reasoning_items: + assert "id" not in it def test_preflight_codex_input_deduplicates_reasoning_ids(monkeypatch): @@ -1272,7 +1276,11 @@ def test_preflight_codex_input_deduplicates_reasoning_ids(monkeypatch): normalized = agent._preflight_codex_input_items(raw_input) reasoning_items = [it for it in normalized if it.get("type") == "reasoning"] - reasoning_ids = [it["id"] for it in reasoning_items] - assert reasoning_ids.count("rs_xyz") == 1 - assert reasoning_ids.count("rs_zzz") == 1 + # rs_xyz duplicate should be collapsed to one item; rs_zzz kept. assert len(reasoning_items) == 2 + encrypted = [it["encrypted_content"] for it in reasoning_items] + assert encrypted.count("enc_a") == 1 + assert "enc_b" in encrypted + # IDs must be stripped — with store=False the API 404s on id lookups. + for it in reasoning_items: + assert "id" not in it From 7b2700c9afca19f3653a0af98d5ee35dd39bc9c6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:29:37 -0700 Subject: [PATCH 161/849] fix(browser): use 127.0.0.1 instead of localhost for CDP default (#10231) /browser connect set BROWSER_CDP_URL to http://localhost:9222, but Chrome's --remote-debugging-port only binds to 127.0.0.1 (IPv4). On macOS, 'localhost' can resolve to ::1 (IPv6) first, causing both _resolve_cdp_override's /json/version fetch and agent-browser's --cdp connection to fail when Chrome isn't listening on IPv6. The socket check in the connect handler already used 127.0.0.1 explicitly and succeeded, masking the mismatch. Use 127.0.0.1 in the default CDP URL to match what Chrome actually binds to. --- cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 0ed2d3232..97698f133 100644 --- a/cli.py +++ b/cli.py @@ -5956,7 +5956,7 @@ class HermesCLI: parts = cmd.strip().split(None, 1) sub = parts[1].lower().strip() if len(parts) > 1 else "status" - _DEFAULT_CDP = "http://localhost:9222" + _DEFAULT_CDP = "http://127.0.0.1:9222" current = os.environ.get("BROWSER_CDP_URL", "").strip() if sub.startswith("connect"): From 2546b7acea9b294429396e9196374127acd71024 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 03:31:08 -0700 Subject: [PATCH 162/849] fix(gateway): suppress duplicate replies on interrupt and streaming flood control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the duplicate reply bug affecting all gateway platforms: 1. base.py: Suppress stale response when the session was interrupted by a new message that hasn't been consumed yet. Checks both interrupt_event and _pending_messages to avoid false positives. (#8221, #2483) 2. run.py (return path): Remove response_previewed guard from already_sent check. Stream consumer's already_sent alone is authoritative — if content was delivered via streaming, the duplicate send must be suppressed regardless of the agent's response_previewed flag. (#8375) 3. run.py (queued-message path): Same fix — already_sent without response_previewed now correctly marks the first response as already streamed, preventing re-send before processing the queued message. The response_previewed field is still produced by the agent (run_agent.py) but is no longer required as a gate for duplicate suppression. The stream consumer's already_sent flag is the delivery-level truth about what the user actually saw. Concepts from PR #8380 (konsisumer). Closes #8375, #8221, #2483. --- gateway/platforms/base.py | 15 + gateway/run.py | 12 +- .../test_duplicate_reply_suppression.py | 291 ++++++++++++++++++ 3 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 tests/gateway/test_duplicate_reply_suppression.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index f7943da47..1561cd526 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1624,6 +1624,21 @@ class BasePlatformAdapter(ABC): # streaming already delivered the text (already_sent=True) or # when the message was queued behind an active agent. Log at # DEBUG to avoid noisy warnings for expected behavior. + # + # Suppress stale response when the session was interrupted by a + # new message that hasn't been consumed yet. The pending message + # is processed by the pending-message handler below (#8221/#2483). + if ( + response + and interrupt_event.is_set() + and session_key in self._pending_messages + ): + logger.info( + "[%s] Suppressing stale response for interrupted session %s", + self.name, + session_key, + ) + response = None if not response: logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id) if response: diff --git a/gateway/run.py b/gateway/run.py index d360d453f..2eb745f92 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9231,15 +9231,11 @@ class GatewayRunner: pass except Exception as e: logger.debug("Stream consumer wait before queued message failed: %s", e) - _response_previewed = bool(result.get("response_previewed")) _already_streamed = bool( _sc and ( getattr(_sc, "final_response_sent", False) - or ( - _response_previewed - and getattr(_sc, "already_sent", False) - ) + or getattr(_sc, "already_sent", False) ) ) first_response = result.get("final_response", "") @@ -9323,13 +9319,9 @@ class GatewayRunner: # them even if streaming had sent earlier partial output. _sc = stream_consumer_holder[0] if _sc and isinstance(response, dict) and not response.get("failed"): - _response_previewed = bool(response.get("response_previewed")) if ( getattr(_sc, "final_response_sent", False) - or ( - _response_previewed - and getattr(_sc, "already_sent", False) - ) + or getattr(_sc, "already_sent", False) ): response["already_sent"] = True diff --git a/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py new file mode 100644 index 000000000..5a0ea02f3 --- /dev/null +++ b/tests/gateway/test_duplicate_reply_suppression.py @@ -0,0 +1,291 @@ +"""Tests for duplicate reply suppression across the gateway stack. + +Covers three fix paths: + 1. base.py: stale response suppressed when interrupt_event is set and a + pending message exists (#8221 / #2483) + 2. run.py return path: already_sent propagated from stream consumer's + already_sent flag without requiring response_previewed (#8375) + 3. run.py queued-message path: first response correctly detected as + already-streamed when already_sent is True without response_previewed +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, +) +from gateway.session import SessionSource, build_session_key + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class StubAdapter(BasePlatformAdapter): + """Minimal concrete adapter for testing.""" + + def __init__(self): + super().__init__(PlatformConfig(enabled=True, token="fake"), Platform.DISCORD) + self.sent = [] + + async def connect(self): + return True + + async def disconnect(self): + pass + + async def send(self, chat_id, content, reply_to=None, metadata=None): + self.sent.append({"chat_id": chat_id, "content": content}) + return SendResult(success=True, message_id="msg1") + + async def send_typing(self, chat_id, metadata=None): + pass + + async def get_chat_info(self, chat_id): + return {"id": chat_id} + + +def _make_event(text="hello", chat_id="c1", user_id="u1"): + return MessageEvent( + text=text, + source=SessionSource( + platform=Platform.DISCORD, + chat_id=chat_id, + chat_type="dm", + user_id=user_id, + ), + message_id="m1", + ) + + +# =================================================================== +# Test 1: base.py — stale response suppressed on interrupt (#8221) +# =================================================================== + +class TestBaseInterruptSuppression: + @pytest.mark.asyncio + async def test_stale_response_suppressed_when_interrupted(self): + """When interrupt_event is set AND a pending message exists, + base.py should suppress the stale response instead of sending it.""" + adapter = StubAdapter() + + stale_response = "This is the stale answer to the first question." + pending_response = "This is the answer to the second question." + call_count = 0 + + async def fake_handler(event): + nonlocal call_count + call_count += 1 + if call_count == 1: + return stale_response + return pending_response + + adapter.set_message_handler(fake_handler) + + event_a = _make_event(text="first question") + session_key = build_session_key(event_a.source) + + # Simulate: message A is being processed, message B arrives + # The interrupt event is set and B is in pending_messages + interrupt_event = asyncio.Event() + interrupt_event.set() + adapter._active_sessions[session_key] = interrupt_event + + event_b = _make_event(text="second question") + adapter._pending_messages[session_key] = event_b + + await adapter._process_message_background(event_a, session_key) + + # The stale response should NOT have been sent. + stale_sends = [s for s in adapter.sent if s["content"] == stale_response] + assert len(stale_sends) == 0, ( + f"Stale response was sent {len(stale_sends)} time(s) — should be suppressed" + ) + # The pending message's response SHOULD have been sent. + pending_sends = [s for s in adapter.sent if s["content"] == pending_response] + assert len(pending_sends) == 1, "Pending message response should be sent" + + @pytest.mark.asyncio + async def test_response_not_suppressed_without_interrupt(self): + """Normal case: no interrupt, response should be sent.""" + adapter = StubAdapter() + + async def fake_handler(event): + return "Normal response" + + adapter.set_message_handler(fake_handler) + event = _make_event() + session_key = build_session_key(event.source) + + await adapter._process_message_background(event, session_key) + + assert any(s["content"] == "Normal response" for s in adapter.sent) + + @pytest.mark.asyncio + async def test_response_not_suppressed_with_interrupt_but_no_pending(self): + """Interrupt event set but no pending message (race already resolved) — + response should still be sent.""" + adapter = StubAdapter() + + async def fake_handler(event): + return "Valid response" + + adapter.set_message_handler(fake_handler) + event = _make_event() + session_key = build_session_key(event.source) + + # Set interrupt but no pending message + interrupt_event = asyncio.Event() + interrupt_event.set() + adapter._active_sessions[session_key] = interrupt_event + + await adapter._process_message_background(event, session_key) + + assert any(s["content"] == "Valid response" for s in adapter.sent) + + +# =================================================================== +# Test 2: run.py — already_sent without response_previewed (#8375) +# =================================================================== + +class TestAlreadySentWithoutResponsePreviewed: + """The already_sent flag on the response dict should be set when the + stream consumer's already_sent is True, even if response_previewed is + False. This prevents duplicate sends when streaming was interrupted + by flood control.""" + + def _make_mock_stream_consumer(self, already_sent=False, final_response_sent=False): + sc = SimpleNamespace( + already_sent=already_sent, + final_response_sent=final_response_sent, + ) + return sc + + def test_already_sent_set_without_response_previewed(self): + """Stream consumer already_sent=True should propagate to response + dict even when response_previewed is False.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False) + response = {"final_response": "text", "response_previewed": False} + + # Reproduce the logic from run.py return path (post-fix) + if sc and isinstance(response, dict) and not response.get("failed"): + if ( + getattr(sc, "final_response_sent", False) + or getattr(sc, "already_sent", False) + ): + response["already_sent"] = True + + assert response.get("already_sent") is True + + def test_already_sent_not_set_when_nothing_sent(self): + """When stream consumer hasn't sent anything, already_sent should + not be set on the response.""" + sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=False) + response = {"final_response": "text", "response_previewed": False} + + if sc and isinstance(response, dict) and not response.get("failed"): + if ( + getattr(sc, "final_response_sent", False) + or getattr(sc, "already_sent", False) + ): + response["already_sent"] = True + + assert "already_sent" not in response + + def test_already_sent_set_on_final_response_sent(self): + """final_response_sent=True should still work as before.""" + sc = self._make_mock_stream_consumer(already_sent=False, final_response_sent=True) + response = {"final_response": "text"} + + if sc and isinstance(response, dict) and not response.get("failed"): + if ( + getattr(sc, "final_response_sent", False) + or getattr(sc, "already_sent", False) + ): + response["already_sent"] = True + + assert response.get("already_sent") is True + + def test_already_sent_not_set_on_failed_response(self): + """Failed responses should never be suppressed — user needs to see + the error message even if streaming sent earlier partial output.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False) + response = {"final_response": "Error: something broke", "failed": True} + + if sc and isinstance(response, dict) and not response.get("failed"): + if ( + getattr(sc, "final_response_sent", False) + or getattr(sc, "already_sent", False) + ): + response["already_sent"] = True + + assert "already_sent" not in response + + +# =================================================================== +# Test 3: run.py queued-message path — _already_streamed detection +# =================================================================== + +class TestQueuedMessageAlreadyStreamed: + """The queued-message path should detect that the first response was + already streamed (already_sent=True) even without response_previewed.""" + + def _make_mock_sc(self, already_sent=False, final_response_sent=False): + return SimpleNamespace( + already_sent=already_sent, + final_response_sent=final_response_sent, + ) + + def test_queued_path_detects_already_streamed(self): + """already_sent=True on stream consumer means first response was + streamed — skip re-sending before processing queued message.""" + _sc = self._make_mock_sc(already_sent=True) + + # Reproduce the queued-message logic from run.py (post-fix) + _already_streamed = bool( + _sc + and ( + getattr(_sc, "final_response_sent", False) + or getattr(_sc, "already_sent", False) + ) + ) + + assert _already_streamed is True + + def test_queued_path_sends_when_not_streamed(self): + """Nothing was streamed — first response should be sent before + processing the queued message.""" + _sc = self._make_mock_sc(already_sent=False) + + _already_streamed = bool( + _sc + and ( + getattr(_sc, "final_response_sent", False) + or getattr(_sc, "already_sent", False) + ) + ) + + assert _already_streamed is False + + def test_queued_path_with_no_stream_consumer(self): + """No stream consumer at all (streaming disabled) — not streamed.""" + _sc = None + + _already_streamed = bool( + _sc + and ( + getattr(_sc, "final_response_sent", False) + or getattr(_sc, "already_sent", False) + ) + ) + + assert _already_streamed is False From 8bc9b5a0b4c6ae8ceb33b8a8d4c8d813a280eb6e Mon Sep 17 00:00:00 2001 From: Misturi Date: Wed, 15 Apr 2026 08:54:33 +0100 Subject: [PATCH 163/849] fix(skills): use `is None` check for coordinates in find-nearby to avoid dropping valid 0.0 values --- skills/leisure/find-nearby/scripts/find_nearby.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/leisure/find-nearby/scripts/find_nearby.py b/skills/leisure/find-nearby/scripts/find_nearby.py index 543d35a0d..9d7fed78f 100644 --- a/skills/leisure/find-nearby/scripts/find_nearby.py +++ b/skills/leisure/find-nearby/scripts/find_nearby.py @@ -98,7 +98,7 @@ def find_nearby(lat: float, lon: float, types: list[str], radius: int = 1500, li # Get coordinates (nodes have lat/lon directly, ways/relations use center) plat = el.get("lat") or (el.get("center", {}) or {}).get("lat") plon = el.get("lon") or (el.get("center", {}) or {}).get("lon") - if not plat or not plon: + if plat is None or plon is None: continue dist = haversine(lat, lon, plat, plon) From dedc4600dd31770612af213167f7b4808fafa3d8 Mon Sep 17 00:00:00 2001 From: Misturi Date: Wed, 15 Apr 2026 09:15:44 +0100 Subject: [PATCH 164/849] fix(skills): handle missing fields in Google Workspace token file gracefully instead of crashing with KeyError --- skills/productivity/google-workspace/scripts/gws_bridge.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py index adecd33ad..7b5d351f8 100755 --- a/skills/productivity/google-workspace/scripts/gws_bridge.py +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -25,6 +25,13 @@ def refresh_token(token_data: dict) -> dict: import urllib.parse import urllib.request + required_keys = ["client_id", "client_secret", "refresh_token", "token_uri"] + missing = [k for k in required_keys if k not in token_data] + if missing: + print(f"ERROR: google_token.json is missing required fields: {', '.join(missing)}", file=sys.stderr) + print("Please re-authenticate by running the Google Workspace setup script.", file=sys.stderr) + sys.exit(1) + params = urllib.parse.urlencode({ "client_id": token_data["client_id"], "client_secret": token_data["client_secret"], From 1c4d3216d3855848a632847306c96f4c3090b259 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:46:58 -0700 Subject: [PATCH 165/849] fix(cron): include job_id in delivery and guide models on removal workflow (#10242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): suppress duplicate replies on interrupt and streaming flood control Three fixes for the duplicate reply bug affecting all gateway platforms: 1. base.py: Suppress stale response when the session was interrupted by a new message that hasn't been consumed yet. Checks both interrupt_event and _pending_messages to avoid false positives. (#8221, #2483) 2. run.py (return path): Remove response_previewed guard from already_sent check. Stream consumer's already_sent alone is authoritative — if content was delivered via streaming, the duplicate send must be suppressed regardless of the agent's response_previewed flag. (#8375) 3. run.py (queued-message path): Same fix — already_sent without response_previewed now correctly marks the first response as already streamed, preventing re-send before processing the queued message. The response_previewed field is still produced by the agent (run_agent.py) but is no longer required as a gate for duplicate suppression. The stream consumer's already_sent flag is the delivery-level truth about what the user actually saw. Concepts from PR #8380 (konsisumer). Closes #8375, #8221, #2483. * fix(cron): include job_id in delivery and guide models on removal workflow Users reported cron reminders keep firing after asking the agent to stop. Root cause: the conversational agent didn't know the job_id (not in delivery) and models don't reliably do the list→remove two-step without guidance. 1. Include job_id in the cron delivery wrapper so users and agents can reference it when requesting removal. 2. Replace confusing footer ('The agent cannot see this message') with actionable guidance ('To stop or manage this job, send me a new message'). 3. Add explicit list→remove guidance in the cronjob tool schema so models know to list first and never guess job IDs. --- cron/scheduler.py | 4 +++- tests/cron/test_scheduler.py | 3 ++- tools/cronjob_tools.py | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 83b7abb9b..cd4576c9f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -288,11 +288,13 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option if wrap_response: task_name = job.get("name", job["id"]) + job_id = job.get("id", "") delivery_content = ( f"Cronjob Response: {task_name}\n" + f"(job_id: {job_id})\n" f"-------------\n\n" f"{content}\n\n" - f"Note: The agent cannot see this message, and therefore cannot respond to it." + f"To stop or manage this job, send me a new message (e.g. \"stop reminder {task_name}\")." ) else: delivery_content = content diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 08b57cfa8..50d3cf14f 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -233,9 +233,10 @@ class TestDeliverResultWrapping: send_mock.assert_called_once() sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1] assert "Cronjob Response: daily-report" in sent_content + assert "(job_id: test-job)" in sent_content assert "-------------" in sent_content assert "Here is today's summary." in sent_content - assert "The agent cannot see this message" in sent_content + assert "To stop or manage this job" in sent_content def test_delivery_uses_job_id_when_no_name(self): """When a job has no name, the wrapper should fall back to job id.""" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 75dd4c31f..25a153041 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -391,6 +391,8 @@ Use action='create' to schedule a new job from a prompt or one or more skills. Use action='list' to inspect jobs. Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. +To stop a job the user no longer wants: first action='list' to find the job_id, then action='remove' with that job_id. Never guess job IDs — always list first. + Jobs run in a fresh session with no current-chat context, so prompts must be self-contained. If skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. On update, passing skills=[] clears attached skills. From 4bcb2f2d2632011008b11d40fe2aca32c94585e0 Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Wed, 15 Apr 2026 11:46:25 +0300 Subject: [PATCH 166/849] feat(send_message): add native media attachment support for Discord Previously send_message only supported media delivery for Telegram. Discord users received a warning that media was omitted. - Add media_files parameter to _send_discord() - Upload media via Discord multipart/form-data API (files[0] field) - Handle Discord in _send_to_platform() same way as Telegram block - Remove Discord from generic chunk loop (now handled above) - Update error/warning strings to mention telegram and discord --- tools/send_message_tool.py | 88 ++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 6c9632b2c..48468e103 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -387,11 +387,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if platform == Platform.WEIXIN: return await _send_weixin(pconfig, chat_id, message, media_files=media_files) - # --- Non-Telegram platforms --- + # --- Discord: special handling for media attachments --- + if platform == Platform.DISCORD: + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_discord( + pconfig.token, + chat_id, + chunk, + media_files=media_files if is_last else [], + thread_id=thread_id, + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + + # --- Non-Telegram/Discord platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram; " + f"send_message MEDIA delivery is currently only supported for telegram and discord; " f"target {platform.value} had only media attachments" ) } @@ -399,14 +416,12 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram" + "native send_message media delivery is currently only supported for telegram and discord" ) last_result = None for chunk in chunks: - if platform == Platform.DISCORD: - result = await _send_discord(pconfig.token, chat_id, chunk, thread_id=thread_id) - elif platform == Platform.SLACK: + if platform == Platform.SLACK: result = await _send_slack(pconfig.token, chat_id, chunk) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) @@ -571,13 +586,16 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_discord(token, chat_id, message, thread_id=None): +async def _send_discord(token, chat_id, message, thread_id=None, media_files=None): """Send a single message via Discord REST API (no websocket client needed). Chunking is handled by _send_to_platform() before this is called. When thread_id is provided, the message is sent directly to that thread via the /channels/{thread_id}/messages endpoint. + + Media files are uploaded one-by-one via multipart/form-data after the + text message is sent (same pattern as Telegram). """ try: import aiohttp @@ -592,14 +610,56 @@ async def _send_discord(token, chat_id, message, thread_id=None): url = f"https://discord.com/api/v10/channels/{thread_id}/messages" else: url = f"https://discord.com/api/v10/channels/{chat_id}/messages" - headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} + auth_headers = {"Authorization": f"Bot {token}"} + media_files = media_files or [] + last_data = None + warnings = [] + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: - async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp: - if resp.status not in (200, 201): - body = await resp.text() - return _error(f"Discord API error ({resp.status}): {body}") - data = await resp.json() - return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": data.get("id")} + # Send text message (skip if empty and media is present) + if message.strip() or not media_files: + headers = {**auth_headers, "Content-Type": "application/json"} + async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp: + if resp.status not in (200, 201): + body = await resp.text() + return _error(f"Discord API error ({resp.status}): {body}") + last_data = await resp.json() + + # Send each media file as a separate multipart upload + for media_path, _is_voice in media_files: + if not os.path.exists(media_path): + warning = f"Media file not found, skipping: {media_path}" + logger.warning(warning) + warnings.append(warning) + continue + try: + form = aiohttp.FormData() + filename = os.path.basename(media_path) + with open(media_path, "rb") as f: + form.add_field("files[0]", f, filename=filename) + async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: + if resp.status not in (200, 201): + body = await resp.text() + warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") + logger.error(warning) + warnings.append(warning) + continue + last_data = await resp.json() + except Exception as e: + warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}") + logger.error(warning) + warnings.append(warning) + + if last_data is None: + error = "No deliverable text or media remained after processing" + if warnings: + return {"error": error, "warnings": warnings} + return {"error": error} + + result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")} + if warnings: + result["warnings"] = warnings + return result except Exception as e: return _error(f"Discord send failed: {e}") From 47e6ea84bb362f951e5d603ec7672731c0539c4e Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 03:53:14 -0700 Subject: [PATCH 167/849] fix: file handle bug, warning text, and tests for Discord media send - Fix file handle closed before POST: nest session.post() inside the 'with open()' block so aiohttp can read the file during upload - Update warning text to include weixin (also supports media delivery) - Add 8 unit tests covering: text+media, media-only, missing files, upload failures, multiple files, and _send_to_platform routing --- tests/tools/test_send_message_tool.py | 186 ++++++++++++++++++++++++++ tools/send_message_tool.py | 20 +-- 2 files changed, 196 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 9a277d598..07a1a9beb 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -888,6 +888,192 @@ class TestSendToPlatformDiscordThread: assert call_kwargs["thread_id"] is None +# --------------------------------------------------------------------------- +# Discord media attachment support +# --------------------------------------------------------------------------- + + +class TestSendDiscordMedia: + """_send_discord uploads media files via multipart/form-data.""" + + @staticmethod + def _build_mock(response_status, response_data=None, response_text="error body"): + """Build a properly-structured aiohttp mock chain.""" + mock_resp = MagicMock() + mock_resp.status = response_status + mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"}) + mock_resp.text = AsyncMock(return_value=response_text) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock(return_value=mock_resp) + + return mock_session, mock_resp + + def test_text_and_media_sends_both(self, tmp_path): + """Text message is sent first, then each media file as multipart.""" + img = tmp_path / "photo.png" + img.write_bytes(b"\x89PNG fake image data") + + mock_session, _ = self._build_mock(200, {"id": "msg999"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "111", "hello", media_files=[(str(img), False)]) + ) + + assert result["success"] is True + assert result["message_id"] == "msg999" + # Two POSTs: one text JSON, one multipart upload + assert mock_session.post.call_count == 2 + + def test_media_only_skips_text_post(self, tmp_path): + """When message is empty and media is present, text POST is skipped.""" + img = tmp_path / "photo.png" + img.write_bytes(b"\x89PNG fake image data") + + mock_session, _ = self._build_mock(200, {"id": "media_only"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "222", " ", media_files=[(str(img), False)]) + ) + + assert result["success"] is True + # Only one POST: the media upload (text was whitespace-only) + assert mock_session.post.call_count == 1 + + def test_missing_media_file_collected_as_warning(self): + """Non-existent media paths produce warnings but don't fail.""" + mock_session, _ = self._build_mock(200, {"id": "txt_ok"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "333", "hello", media_files=[("/nonexistent/file.png", False)]) + ) + + assert result["success"] is True + assert "warnings" in result + assert any("not found" in w for w in result["warnings"]) + # Only the text POST was made, media was skipped + assert mock_session.post.call_count == 1 + + def test_media_upload_failure_collected_as_warning(self, tmp_path): + """Failed media upload becomes a warning, text still succeeds.""" + img = tmp_path / "photo.png" + img.write_bytes(b"\x89PNG fake image data") + + # First call (text) succeeds, second call (media) returns 413 + text_resp = MagicMock() + text_resp.status = 200 + text_resp.json = AsyncMock(return_value={"id": "txt_ok"}) + text_resp.__aenter__ = AsyncMock(return_value=text_resp) + text_resp.__aexit__ = AsyncMock(return_value=None) + + media_resp = MagicMock() + media_resp.status = 413 + media_resp.text = AsyncMock(return_value="Request Entity Too Large") + media_resp.__aenter__ = AsyncMock(return_value=media_resp) + media_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock(side_effect=[text_resp, media_resp]) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "444", "hello", media_files=[(str(img), False)]) + ) + + assert result["success"] is True + assert result["message_id"] == "txt_ok" + assert "warnings" in result + assert any("413" in w for w in result["warnings"]) + + def test_no_text_no_media_returns_error(self): + """Empty text with no media returns error dict.""" + mock_session, _ = self._build_mock(200) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "555", "", media_files=[]) + ) + + # Text is empty but media_files is empty, so text POST fires + # (the "skip text if media present" condition isn't met) + assert result["success"] is True + + def test_multiple_media_files_uploaded_separately(self, tmp_path): + """Each media file gets its own multipart POST.""" + img1 = tmp_path / "a.png" + img1.write_bytes(b"img1") + img2 = tmp_path / "b.jpg" + img2.write_bytes(b"img2") + + mock_session, _ = self._build_mock(200, {"id": "last"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_discord("tok", "666", "hi", media_files=[ + (str(img1), False), (str(img2), False) + ]) + ) + + assert result["success"] is True + # 1 text POST + 2 media POSTs = 3 + assert mock_session.post.call_count == 3 + + +class TestSendToPlatformDiscordMedia: + """_send_to_platform routes Discord media correctly.""" + + def test_media_files_passed_on_last_chunk_only(self): + """Discord media_files are only passed on the final chunk.""" + call_log = [] + + async def mock_send_discord(token, chat_id, message, thread_id=None, media_files=None): + call_log.append({"message": message, "media_files": media_files or []}) + return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": "1"} + + # A message long enough to get chunked (Discord limit is 2000) + long_msg = "A" * 1900 + " " + "B" * 1900 + + with patch("tools.send_message_tool._send_discord", side_effect=mock_send_discord): + result = asyncio.run( + _send_to_platform( + Platform.DISCORD, + SimpleNamespace(enabled=True, token="tok", extra={}), + "999", + long_msg, + media_files=[("/fake/img.png", False)], + ) + ) + + assert result["success"] is True + assert len(call_log) == 2 # Message was chunked + assert call_log[0]["media_files"] == [] # First chunk: no media + assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached + + def test_single_chunk_gets_media(self): + """Short message (single chunk) gets media_files directly.""" + send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) + + with patch("tools.send_message_tool._send_discord", send_mock): + result = asyncio.run( + _send_to_platform( + Platform.DISCORD, + SimpleNamespace(enabled=True, token="tok", extra={}), + "888", + "short message", + media_files=[("/fake/img.png", False)], + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once() + call_kwargs = send_mock.await_args.kwargs + assert call_kwargs["media_files"] == [("/fake/img.png", False)] + + class TestSendMatrixUrlEncoding: """_send_matrix URL-encodes Matrix room IDs in the API path.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 48468e103..1c6417105 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -408,7 +408,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram and discord; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, and weixin; " f"target {platform.value} had only media attachments" ) } @@ -416,7 +416,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram and discord" + "native send_message media delivery is currently only supported for telegram, discord, and weixin" ) last_result = None @@ -637,14 +637,14 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non filename = os.path.basename(media_path) with open(media_path, "rb") as f: form.add_field("files[0]", f, filename=filename) - async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in (200, 201): - body = await resp.text() - warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") - logger.error(warning) - warnings.append(warning) - continue - last_data = await resp.json() + async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: + if resp.status not in (200, 201): + body = await resp.text() + warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") + logger.error(warning) + warnings.append(warning) + continue + last_data = await resp.json() except Exception as e: warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}") logger.error(warning) From 33ae403890718a341a780e4a19001b1fb5bcdf76 Mon Sep 17 00:00:00 2001 From: asheriif Date: Wed, 15 Apr 2026 10:24:57 +0000 Subject: [PATCH 168/849] fix(gateway): fix matrix lingering typing indicator --- gateway/platforms/matrix.py | 8 ++++++++ tests/gateway/test_matrix.py | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 816d88b03..4aebd92b1 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -729,6 +729,14 @@ class MatrixAdapter(BasePlatformAdapter): except Exception: pass + async def stop_typing(self, chat_id: str) -> None: + """Stop the Matrix typing indicator.""" + if self._client: + try: + await self._client.set_typing(RoomID(chat_id), timeout=0) + except Exception: + pass + async def edit_message( self, chat_id: str, message_id: str, content: str ) -> SendResult: diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 5097ab633..90d820046 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -335,6 +335,29 @@ def _make_adapter(): return adapter +# --------------------------------------------------------------------------- +# Typing indicator +# --------------------------------------------------------------------------- + +class TestMatrixTypingIndicator: + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._client = MagicMock() + self.adapter._client.set_typing = AsyncMock() + + @pytest.mark.asyncio + async def test_stop_typing_clears_matrix_typing_state(self): + """stop_typing() should send typing=false instead of waiting for timeout expiry.""" + from gateway.platforms.matrix import RoomID + + await self.adapter.stop_typing("!room:example.org") + + self.adapter._client.set_typing.assert_awaited_once_with( + RoomID("!room:example.org"), + timeout=0, + ) + + # --------------------------------------------------------------------------- # mxc:// URL conversion # --------------------------------------------------------------------------- @@ -1831,4 +1854,3 @@ class TestMatrixPresence: assert result is False - From 4da598b48ab5be2fee8d24c9d3f9cf9abb095b91 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:39:34 -0700 Subject: [PATCH 169/849] =?UTF-8?q?docs:=20clarify=20hermes=20model=20vs?= =?UTF-8?q?=20/model=20=E2=80=94=20two=20commands,=20two=20purposes=20(#10?= =?UTF-8?q?276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users are confused about the difference between `hermes model` (terminal command for full provider setup) and `/model` (session command for switching between already-configured providers). This distinction was not documented anywhere. Changes across 4 doc pages: - cli-commands.md: Added warning callout explaining the difference, added --global flag docs, added 'only see OpenRouter models?' info box - slash-commands.md: Added notes on both TUI and messaging /model entries that /model only switches between configured providers - providers.md: Added 'Two Commands for Model Management' comparison table near top of page, added warning callout in switching section - faq.md: Added new FAQ entry '/model only shows one provider' with quick reference table Prompted by user feedback in Discord — new users consistently hit this confusion when trying to add providers from inside a session. --- website/docs/integrations/providers.md | 21 +++++++++++++++++- website/docs/reference/cli-commands.md | 27 ++++++++++++++++++++---- website/docs/reference/faq.md | 26 +++++++++++++++++++++++ website/docs/reference/slash-commands.md | 4 ++-- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index a44483a00..22deca638 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -49,6 +49,17 @@ The OpenAI Codex provider authenticates via device code (open a URL, enter a cod Even when using Nous Portal, Codex, or a custom endpoint, some tools (vision, web summarization, MoA) use a separate "auxiliary" model — by default Gemini Flash via OpenRouter. An `OPENROUTER_API_KEY` enables these tools automatically. You can also configure which model and provider these tools use — see [Auxiliary Models](/docs/user-guide/configuration#auxiliary-models). ::: +### Two Commands for Model Management + +Hermes has **two** model commands that serve different purposes: + +| Command | Where to run | What it does | +|---------|-------------|--------------| +| **`hermes model`** | Your terminal (outside any session) | Full setup wizard — add providers, run OAuth, enter API keys, configure endpoints | +| **`/model`** | Inside a Hermes chat session | Quick switch between **already-configured** providers and models | + +If you're trying to switch to a provider you haven't set up yet (e.g. you only have OpenRouter configured and want to use Anthropic), you need `hermes model`, not `/model`. Exit your session first (`Ctrl+C` or `/quit`), run `hermes model`, complete the provider setup, then start a new session. + ### Anthropic (Native) Use Claude models directly through the Anthropic API — no OpenRouter proxy needed. Supports three auth methods: @@ -252,7 +263,15 @@ Both approaches persist to `config.yaml`, which is the source of truth for model ### Switching Models with `/model` -Once a custom endpoint is configured, you can switch models mid-session: +:::warning hermes model vs /model +**`hermes model`** (run from your terminal, outside any chat session) is the **full provider setup wizard**. Use it to add new providers, run OAuth flows, enter API keys, and configure custom endpoints. + +**`/model`** (typed inside an active Hermes chat session) can only **switch between providers and models you've already set up**. It cannot add new providers, run OAuth, or prompt for API keys. If you've only configured one provider (e.g. OpenRouter), `/model` will only show models for that provider. + +**To add a new provider:** Exit your session (`Ctrl+C` or `/quit`), run `hermes model`, set up the new provider, then start a new session. +::: + +Once you have at least one custom endpoint configured, you can switch models mid-session: ``` /model custom:qwen-2.5 # Switch to a model on your custom endpoint diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 2e054482f..fb93cf648 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -109,22 +109,31 @@ hermes chat --worktree -q "Review this repo and open a PR" ## `hermes model` -Interactive provider + model selector. +Interactive provider + model selector. **This is the command for adding new providers, setting up API keys, and running OAuth flows.** Run it from your terminal — not from inside an active Hermes chat session. ```bash hermes model ``` Use this when you want to: -- switch default providers -- log into OAuth-backed providers during model selection +- **add a new provider** (OpenRouter, Anthropic, Copilot, DeepSeek, custom, etc.) +- log into OAuth-backed providers (Anthropic, Copilot, Codex, Nous Portal) +- enter or update API keys - pick from provider-specific model lists - configure a custom/self-hosted endpoint - save the new default into config +:::warning hermes model vs /model — know the difference +**`hermes model`** (run from your terminal, outside any Hermes session) is the **full provider setup wizard**. It can add new providers, run OAuth flows, prompt for API keys, and configure endpoints. + +**`/model`** (typed inside an active Hermes chat session) can only **switch between providers and models you've already set up**. It cannot add new providers, run OAuth, or prompt for API keys. + +**If you need to add a new provider:** Exit your Hermes session first (`Ctrl+C` or `/quit`), then run `hermes model` from your terminal prompt. +::: + ### `/model` slash command (mid-session) -Switch models without leaving a session: +Switch between already-configured models without leaving a session: ``` /model # Show current model and available options @@ -136,6 +145,16 @@ Switch models without leaving a session: /model openrouter:anthropic/claude-sonnet-4 # Switch back to cloud ``` +By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml`: + +``` +/model claude-sonnet-4 --global # Switch and save as new default +``` + +:::info What if I only see OpenRouter models? +If you've only configured OpenRouter, `/model` will only show OpenRouter models. To add another provider (Anthropic, DeepSeek, Copilot, etc.), exit your session and run `hermes model` from the terminal. +::: + Provider and base URL changes are persisted to `config.yaml` automatically. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers. ## `hermes gateway` diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 6950fb1e9..c39f510b1 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -187,6 +187,32 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri ### Provider & Model Issues +#### `/model` only shows one provider / can't switch providers + +**Cause:** `/model` (inside a chat session) can only switch between providers you've **already configured**. If you've only set up OpenRouter, that's all `/model` will show. + +**Solution:** Exit your session and use `hermes model` from your terminal to add new providers: + +```bash +# Exit the Hermes chat session first (Ctrl+C or /quit) + +# Run the full provider setup wizard +hermes model + +# This lets you: add providers, run OAuth, enter API keys, configure endpoints +``` + +After adding a new provider via `hermes model`, start a new chat session — `/model` will now show all your configured providers. + +:::tip Quick reference +| Want to... | Use | +|-----------|-----| +| Add a new provider | `hermes model` (from terminal) | +| Enter/change API keys | `hermes model` (from terminal) | +| Switch model mid-session | `/model ` (inside session) | +| Switch to different configured provider | `/model provider:model` (inside session) | +::: + #### API key not working **Cause:** Key is missing, expired, incorrectly set, or for the wrong provider. diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 8e65d81f7..2ad3c62d8 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -46,7 +46,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| | `/config` | Show current configuration | -| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint). Use `--global` to persist the change to config.yaml. | +| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. | | `/provider` | Show available providers and current provider | | `/personality` | Set a predefined personality | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | @@ -124,7 +124,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/reset` | Reset conversation history. | | `/status` | Show session info. | | `/stop` | Kill all running background processes and interrupt the running agent. | -| `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), and auto-detect (`/model custom`). Use `--global` to persist the change to config.yaml. | +| `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), and auto-detect (`/model custom`). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). | | `/provider` | Show provider availability and auth status. | | `/personality [name]` | Set a personality overlay for the session. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. | From 41e2d61b3fccc7bd9c3c060d66d80ee21c2dcb8c Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Wed, 15 Apr 2026 14:34:32 +0300 Subject: [PATCH 170/849] feat(discord): add native send_animation for inline GIF playback --- gateway/platforms/discord.py | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index a80790ed5..2d2ea93f9 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1379,6 +1379,68 @@ class DiscordAdapter(BasePlatformAdapter): ) return await super().send_image(chat_id, image_url, caption, reply_to) + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an animated GIF natively as a Discord file attachment.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + if not is_safe_url(animation_url): + logger.warning("[%s] Blocked unsafe animation URL during Discord send_animation", self.name) + return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) + + try: + import aiohttp + + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + # Download the GIF and send as a Discord file attachment + # (Discord renders .gif attachments as auto-playing animations inline) + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + async with aiohttp.ClientSession(**_sess_kw) as session: + async with session.get(animation_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp: + if resp.status != 200: + raise Exception(f"Failed to download animation: HTTP {resp.status}") + + animation_data = await resp.read() + + import io + file = discord.File(io.BytesIO(animation_data), filename="animation.gif") + + msg = await channel.send( + content=caption if caption else None, + file=file, + ) + return SendResult(success=True, message_id=str(msg.id)) + + except ImportError: + logger.warning( + "[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", + self.name, + exc_info=True, + ) + return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[%s] Failed to send animation attachment, falling back to URL: %s", + self.name, + e, + exc_info=True, + ) + return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) + async def send_video( self, chat_id: str, From 722331a57de9e18f134c896d733870b4a493dc84 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:57:55 -0700 Subject: [PATCH 171/849] fix: replace hardcoded ~/.hermes with display_hermes_home() in agent-facing text (#10285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool schema descriptions and tool return values contained hardcoded ~/.hermes paths that the model sees and uses. When HERMES_HOME is set to a custom path (Docker containers, profiles), the agent would still reference ~/.hermes — looking at the wrong directory. Fixes 6 locations across 5 files: - tools/tts_tool.py: output_path schema description - tools/cronjob_tools.py: script path schema description - tools/skill_manager_tool.py: skill_manage schema description - tools/skills_tool.py: two tool return messages - agent/skill_commands.py: skill config injection text All now use display_hermes_home() which resolves to the actual HERMES_HOME path (e.g. /opt/data for Docker, ~/.hermes/profiles/X for profiles, ~/.hermes for default). Reported by: Sandeep Narahari (PrithviDevs) --- agent/skill_commands.py | 4 +++- tools/cronjob_tools.py | 4 +++- tools/skill_manager_tool.py | 4 ++-- tools/skills_tool.py | 6 +++--- tools/tts_tool.py | 4 +++- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 1f000eefe..149b4aaeb 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -12,6 +12,8 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional +from hermes_constants import display_hermes_home + logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} @@ -108,7 +110,7 @@ def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None if not resolved: return - lines = ["", "[Skill config (from ~/.hermes/config.yaml):"] + lines = ["", f"[Skill config (from {display_hermes_home()}/config.yaml):"] for key, value in resolved.items(): display_val = str(value) if value else "(not set)" lines.append(f" {key} = {display_val}") diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 25a153041..8a685a8cc 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -13,6 +13,8 @@ import sys from pathlib import Path from typing import Any, Dict, List, Optional +from hermes_constants import display_hermes_home + logger = logging.getLogger(__name__) # Import from cron module (will be available when properly installed) @@ -455,7 +457,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "script": { "type": "string", - "description": "Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under ~/.hermes/scripts/. On update, pass empty string to clear." + "description": f"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear." }, }, "required": ["action"] diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 6c7307259..a3e585a58 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -39,7 +39,7 @@ import re import shutil import tempfile from pathlib import Path -from hermes_constants import get_hermes_home +from hermes_constants import get_hermes_home, display_hermes_home from typing import Dict, Any, Optional, Tuple logger = logging.getLogger(__name__) @@ -655,7 +655,7 @@ SKILL_MANAGE_SCHEMA = { "description": ( "Manage skills (create, update, delete). Skills are your procedural " "memory — reusable approaches for recurring task types. " - "New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live.\n\n" + f"New skills go to {display_hermes_home()}/skills/; existing skills can be modified wherever they live.\n\n" "Actions: create (full SKILL.md + optional category), " "patch (old_string/new_string — preferred for fixes), " "edit (full SKILL.md rewrite — major overhauls only), " diff --git a/tools/skills_tool.py b/tools/skills_tool.py index f6328ab0b..340e4ed53 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -69,7 +69,7 @@ Usage: import json import logging -from hermes_constants import get_hermes_home +from hermes_constants import get_hermes_home, display_hermes_home import os import re from enum import Enum @@ -408,7 +408,7 @@ def _gateway_setup_hint() -> str: return GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE except Exception: - return "Secure secret entry is not available. Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually." + return f"Secure secret entry is not available. Load this skill in the local CLI to be prompted, or add the key to {display_hermes_home()}/.env manually." def _build_setup_note( @@ -666,7 +666,7 @@ def skills_list(category: str = None, task_id: str = None) -> str: "success": True, "skills": [], "categories": [], - "message": "No skills found. Skills directory created at ~/.hermes/skills/", + "message": f"No skills found. Skills directory created at {display_hermes_home()}/skills/", }, ensure_ascii=False, ) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 769ae30a9..9fdb63866 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -40,6 +40,8 @@ from pathlib import Path from typing import Callable, Dict, Any, Optional from urllib.parse import urljoin +from hermes_constants import display_hermes_home + logger = logging.getLogger(__name__) from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key @@ -1050,7 +1052,7 @@ TTS_SCHEMA = { }, "output_path": { "type": "string", - "description": "Optional custom file path to save the audio. Defaults to ~/.hermes/audio_cache/.mp3" + "description": f"Optional custom file path to save the audio. Defaults to {display_hermes_home()}/audio_cache/.mp3" } }, "required": ["text"] From 33c615504d7256c015f0db34fcb59210d3dce773 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 10:20:56 -0500 Subject: [PATCH 172/849] 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 655e3903d..19d063395 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 78cac4f88..e172cabc2 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 19d162e6d..b4417d3cc 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 000000000..86489e334 --- /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 181b96b43..0a11e3cc0 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 79edcce28..d071ba786 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 8c3158017..6afd5c094 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 9f5df4ca9..eb8fd7eb5 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 cdd9347fb..9187f15a3 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 350687d74..8496008c7 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 c4611f9dc..549e85fe2 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 7e8b31753..8d3df69ee 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 1db4594b9..3f23d3e6c 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 a6a611bc6..e78b7f489 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 be33502ee..46bd330c1 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 e3b646edd..35927f0bd 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 dbcfeb607..392b01c49 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 04f42ec16..8d75713d0 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 a35f3c417..ffa06377b 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 b17eff3ee..9d6a9a58e 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 aac00d667..90eef0c63 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 173/849] 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 d442e138f..21d544d87 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 000000000..3f3191ccf --- /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 174/849] 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 6ad664370..83c32cc80 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 b4417d3cc..38d206baf 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 d071ba786..08b415276 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 8d3df69ee..467b01614 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 e78b7f489..d20e25292 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 35927f0bd..9b7f7b9db 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 d37f86f71..46f6b667f 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 4e546f3d8..3dd8a9d75 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 8d75713d0..7d0717c7a 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 ffa06377b..caf851220 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 aae199324..24f931770 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 0793178fd..369a9f50f 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 c0df224ff..21bdd51c9 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 422f2866e60daa617688043fd9758c6380711d43 Mon Sep 17 00:00:00 2001 From: WideLee Date: Thu, 16 Apr 2026 00:09:57 +0800 Subject: [PATCH 175/849] docs: restore sidebar entries removed by PR #9931 Re-add 'qqbot' and 'automation-templates' doc indexes to sidebars.ts that were accidentally dropped in https://github.com/NousResearch/hermes-agent/pull/9931. --- website/sidebars.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/sidebars.ts b/website/sidebars.ts index a1633f229..02137fd96 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -119,6 +119,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/wecom-callback', 'user-guide/messaging/weixin', 'user-guide/messaging/bluebubbles', + 'user-guide/messaging/qqbot', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], @@ -153,6 +154,7 @@ const sidebars: SidebarsConfig = { 'guides/use-voice-mode-with-hermes', 'guides/build-a-hermes-plugin', 'guides/automate-with-cron', + 'guides/automation-templates', 'guides/cron-troubleshooting', 'guides/work-with-skills', 'guides/delegation-patterns', From aa398ad6553031da2e80d08779904725fe48b992 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:32:24 -0600 Subject: [PATCH 176/849] fix(cron): preserve skill env passthrough in worker thread --- cron/scheduler.py | 7 ++++- tests/cron/test_scheduler.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index cd4576c9f..78a20cf7f 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -10,6 +10,7 @@ runs at a time if multiple processes overlap. import asyncio import concurrent.futures +import contextvars import json import logging import os @@ -770,7 +771,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: _cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None _POLL_INTERVAL = 5.0 _cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - _cron_future = _cron_pool.submit(agent.run_conversation, prompt) + # Preserve scheduler-scoped ContextVar state (for example skill-declared + # env passthrough registrations) when the cron run hops into the worker + # thread used for inactivity timeout monitoring. + _cron_context = contextvars.copy_context() + _cron_future = _cron_pool.submit(_cron_context.run, agent.run_conversation, prompt) _inactivity_timeout = False try: if _cron_inactivity_limit is None: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 50d3cf14f..6ebdaf415 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch, MagicMock import pytest from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, _send_media_via_adapter, run_job, SILENT_MARKER, _build_job_prompt +from tools.env_passthrough import clear_env_passthrough class TestResolveOrigin: @@ -877,6 +878,57 @@ class TestRunJobPerJobOverrides: class TestRunJobSkillBacked: + def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path): + job = { + "id": "skill-env-job", + "name": "skill env test", + "prompt": "Use the skill.", + "skill": "notion", + } + + fake_db = MagicMock() + + def _skill_view(name): + assert name == "notion" + from tools.env_passthrough import register_env_passthrough + + register_env_passthrough(["NOTION_API_KEY"]) + return json.dumps({"success": True, "content": "# notion\nUse Notion."}) + + def _run_conversation(prompt): + from tools.env_passthrough import get_all_passthrough + + assert "NOTION_API_KEY" in get_all_passthrough() + return {"final_response": "ok"} + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("tools.skills_tool.skill_view", side_effect=_skill_view), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.side_effect = _run_conversation + mock_agent_cls.return_value = mock_agent + + try: + success, output, final_response, error = run_job(job) + finally: + clear_env_passthrough() + + assert success is True + assert error is None + assert final_response == "ok" + def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): job = { "id": "skill-job", From da448d4fce50b8ea1f07f93e475a3e59a2b6c867 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:11:08 -0700 Subject: [PATCH 177/849] test(cron): add regression test for credential_files ContextVar propagation (#10462) Follow-up to #10459 (salvage of #7527). The copy_context() fix propagates ALL ContextVars into the cron worker thread, including credential_files. This test verifies that skill-declared required_credential_files are visible inside the worker thread, matching the existing env_passthrough regression test. --- tests/cron/test_scheduler.py | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 6ebdaf415..a1cc2e127 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -9,6 +9,7 @@ import pytest from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, _send_media_via_adapter, run_job, SILENT_MARKER, _build_job_prompt from tools.env_passthrough import clear_env_passthrough +from tools.credential_files import clear_credential_files class TestResolveOrigin: @@ -929,6 +930,66 @@ class TestRunJobSkillBacked: assert error is None assert final_response == "ok" + def test_run_job_preserves_credential_file_passthrough_into_worker_thread(self, tmp_path): + """copy_context() also propagates credential_files ContextVar.""" + job = { + "id": "cred-env-job", + "name": "cred file test", + "prompt": "Use the skill.", + "skill": "google-workspace", + } + + fake_db = MagicMock() + + # Create a credential file so register_credential_file succeeds + cred_dir = tmp_path / "credentials" + cred_dir.mkdir() + (cred_dir / "google_token.json").write_text('{"token": "t"}') + + def _skill_view(name): + assert name == "google-workspace" + from tools.credential_files import register_credential_file + + register_credential_file("credentials/google_token.json") + return json.dumps({"success": True, "content": "# google-workspace\nUse Google."}) + + def _run_conversation(prompt): + from tools.credential_files import _get_registered + + registered = _get_registered() + assert registered, "credential files must be visible in worker thread" + assert any("google_token.json" in v for v in registered.values()) + return {"final_response": "ok"} + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("tools.credential_files._resolve_hermes_home", return_value=tmp_path), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("tools.skills_tool.skill_view", side_effect=_skill_view), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.side_effect = _run_conversation + mock_agent_cls.return_value = mock_agent + + try: + success, output, final_response, error = run_job(job) + finally: + clear_credential_files() + + assert success is True + assert error is None + assert final_response == "ok" + def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): job = { "id": "skill-job", From dee592a0b1c4ce7ae65a5bea3e996dc42a817308 Mon Sep 17 00:00:00 2001 From: etcircle <33860762+etcircle@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:38:57 +0100 Subject: [PATCH 178/849] fix(gateway): route synthetic background events by session --- gateway/run.py | 117 ++++++++++++++---- .../test_background_process_notifications.py | 61 ++++++++- .../test_internal_event_bypass_pairing.py | 53 ++++++++ tests/tools/test_watch_patterns.py | 19 +++ tools/process_registry.py | 12 ++ 5 files changed, 240 insertions(+), 22 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 2eb745f92..670ec4c86 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3958,7 +3958,7 @@ class GatewayRunner: synth_text = _format_gateway_process_notification(evt) if synth_text: try: - await self._inject_watch_notification(synth_text, event) + await self._inject_watch_notification(synth_text, evt) except Exception as e2: logger.error("Watch notification injection error: %s", e2) except Exception as e: @@ -7452,14 +7452,75 @@ class GatewayRunner: return prefix return user_text - async def _inject_watch_notification(self, synth_text: str, original_event) -> None: + def _build_process_event_source(self, evt: dict): + """Resolve the canonical source for a synthetic background-process event. + + Prefer the persisted session-store origin for the event's session key. + Falling back to the currently active foreground event is what causes + cross-topic bleed, so don't do that. + """ + from gateway.session import SessionSource + + session_key = str(evt.get("session_key") or "").strip() + derived_platform = "" + derived_chat_type = "" + derived_chat_id = "" + + if session_key: + try: + self.session_store._ensure_loaded() + entry = self.session_store._entries.get(session_key) + if entry and getattr(entry, "origin", None): + return entry.origin + except Exception as exc: + logger.debug( + "Synthetic process-event session-store lookup failed for %s: %s", + session_key, + exc, + ) + + parts = session_key.split(":") + if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": + derived_platform = parts[2] + derived_chat_type = parts[3] + derived_chat_id = parts[4] + + platform_name = str(evt.get("platform") or derived_platform or "").strip().lower() + chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() + chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip() + if not platform_name or not chat_type or not chat_id: + return None + + try: + platform = Platform(platform_name) + except Exception: + logger.warning( + "Synthetic process event has invalid platform metadata: %r", + platform_name, + ) + return None + + return SessionSource( + platform=platform, + chat_id=chat_id, + chat_type=chat_type, + thread_id=str(evt.get("thread_id") or "").strip() or None, + user_id=str(evt.get("user_id") or "").strip() or None, + user_name=str(evt.get("user_name") or "").strip() or None, + ) + + async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: """Inject a watch-pattern notification as a synthetic message event. - Uses the source from the original user event to route the notification - back to the correct chat/adapter. + Routing must come from the queued watch event itself, not from whatever + foreground message happened to be active when the queue was drained. """ - source = getattr(original_event, "source", None) + source = self._build_process_event_source(evt) if not source: + logger.warning( + "Dropping watch notification with no routing metadata for process %s", + evt.get("session_id", "unknown"), + ) return platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) adapter = None @@ -7477,7 +7538,12 @@ class GatewayRunner: source=source, internal=True, ) - logger.info("Watch pattern notification — injecting for %s", platform_name) + logger.info( + "Watch pattern notification — injecting for %s chat=%s thread=%s", + platform_name, + source.chat_id, + source.thread_id, + ) await adapter.handle_message(synth_event) except Exception as e: logger.error("Watch notification injection error: %s", e) @@ -7547,33 +7613,42 @@ class GatewayRunner: f"Command: {session.command}\n" f"Output:\n{_out}]" ) + source = self._build_process_event_source({ + "session_id": session_id, + "session_key": session_key, + "platform": platform_name, + "chat_id": chat_id, + "thread_id": thread_id, + "user_id": user_id, + "user_name": user_name, + }) + if not source: + logger.warning( + "Dropping completion notification with no routing metadata for process %s", + session_id, + ) + break + adapter = None for p, a in self.adapters.items(): - if p.value == platform_name: + if p == source.platform: adapter = a break - if adapter and chat_id: + if adapter and source.chat_id: try: from gateway.platforms.base import MessageEvent, MessageType - from gateway.session import SessionSource - from gateway.config import Platform - _platform_enum = Platform(platform_name) - _source = SessionSource( - platform=_platform_enum, - chat_id=chat_id, - thread_id=thread_id or None, - user_id=user_id or None, - user_name=user_name or None, - ) synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, - source=_source, + source=source, internal=True, ) logger.info( - "Process %s finished — injecting agent notification for session %s", - session_id, session_key, + "Process %s finished — injecting agent notification for session %s chat=%s thread=%s", + session_id, + session_key, + source.chat_id, + source.thread_id, ) await adapter.handle_message(synth_event) except Exception as e: diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 9c1404f89..90e9e063a 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner: monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner = GatewayRunner(GatewayConfig()) - adapter = SimpleNamespace(send=AsyncMock()) + adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock()) runner.adapters[Platform.TELEGRAM] = adapter return runner @@ -243,3 +243,62 @@ async def test_no_thread_id_sends_no_metadata(monkeypatch, tmp_path): assert adapter.send.await_count == 1 _, kwargs = adapter.send.call_args assert kwargs["metadata"] is None + + +@pytest.mark.asyncio +async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path): + from gateway.session import SessionSource + + runner = _build_runner(monkeypatch, tmp_path, "all") + adapter = runner.adapters[Platform.TELEGRAM] + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="123", + user_name="Emiliyan", + ) + ) + + evt = { + "session_id": "proc_watch", + "session_key": "agent:main:telegram:group:-100:42", + } + + await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt) + + adapter.handle_message.assert_awaited_once() + synth_event = adapter.handle_message.await_args.args[0] + assert synth_event.internal is True + assert synth_event.source.platform == Platform.TELEGRAM + assert synth_event.source.chat_id == "-100" + assert synth_event.source.chat_type == "group" + assert synth_event.source.thread_id == "42" + assert synth_event.source.user_id == "123" + assert synth_event.source.user_name == "Emiliyan" + + +def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path): + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_watch", + "session_key": "agent:main:telegram:group:-100:42", + "platform": "telegram", + "chat_id": "-100", + "thread_id": "42", + "user_id": "123", + "user_name": "Emiliyan", + } + + source = runner._build_process_event_source(evt) + + assert source is not None + assert source.platform == Platform.TELEGRAM + assert source.chat_id == "-100" + assert source.chat_type == "group" + assert source.thread_id == "42" + assert source.user_id == "123" + assert source.user_name == "Emiliyan" diff --git a/tests/gateway/test_internal_event_bypass_pairing.py b/tests/gateway/test_internal_event_bypass_pairing.py index 1c3f9f0c9..d10195b2d 100644 --- a/tests/gateway/test_internal_event_bypass_pairing.py +++ b/tests/gateway/test_internal_event_bypass_pairing.py @@ -230,6 +230,59 @@ async def test_notify_on_complete_preserves_user_identity(monkeypatch, tmp_path) assert event.source.user_name == "alice" +@pytest.mark.asyncio +async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path): + import tools.process_registry as pr_module + from gateway.session import SessionSource + + sessions = [ + SimpleNamespace( + output_buffer="done\n", exited=True, exit_code=0, command="echo test" + ), + ] + monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions)) + + async def _instant_sleep(*_a, **_kw): + pass + monkeypatch.setattr(asyncio, "sleep", _instant_sleep) + + runner = GatewayRunner(GatewayConfig()) + adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock()) + runner.adapters[Platform.TELEGRAM] = adapter + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="user-42", + user_name="alice", + ) + ) + + watcher = { + "session_id": "proc_test_internal", + "check_interval": 0, + "session_key": "agent:main:telegram:group:-100:42", + "platform": "telegram", + "chat_id": "-100", + "thread_id": "42", + "notify_on_complete": True, + } + + await runner._run_process_watcher(watcher) + + assert adapter.handle_message.await_count == 1 + event = adapter.handle_message.await_args.args[0] + assert event.internal is True + assert event.source.platform == Platform.TELEGRAM + assert event.source.chat_id == "-100" + assert event.source.chat_type == "group" + assert event.source.thread_id == "42" + assert event.source.user_id == "user-42" + assert event.source.user_name == "alice" + + @pytest.mark.asyncio async def test_none_user_id_skips_pairing(monkeypatch, tmp_path): """A non-internal event with user_id=None should be silently dropped.""" diff --git a/tests/tools/test_watch_patterns.py b/tests/tools/test_watch_patterns.py index e31844f9f..0621edc14 100644 --- a/tests/tools/test_watch_patterns.py +++ b/tests/tools/test_watch_patterns.py @@ -92,6 +92,25 @@ class TestCheckWatchPatterns: assert "disk full" in evt["output"] assert evt["session_id"] == "proc_test_watch" + def test_match_carries_session_key_and_watcher_routing_metadata(self, registry): + session = _make_session(watch_patterns=["ERROR"]) + session.session_key = "agent:main:telegram:group:-100:42" + session.watcher_platform = "telegram" + session.watcher_chat_id = "-100" + session.watcher_user_id = "u123" + session.watcher_user_name = "alice" + session.watcher_thread_id = "42" + + registry._check_watch_patterns(session, "ERROR: disk full\n") + evt = registry.completion_queue.get_nowait() + + assert evt["session_key"] == "agent:main:telegram:group:-100:42" + assert evt["platform"] == "telegram" + assert evt["chat_id"] == "-100" + assert evt["user_id"] == "u123" + assert evt["user_name"] == "alice" + assert evt["thread_id"] == "42" + def test_multiple_patterns(self, registry): """First matching pattern is reported.""" session = _make_session(watch_patterns=["WARN", "ERROR"]) diff --git a/tools/process_registry.py b/tools/process_registry.py index a5dbc3b1b..3a274eaa3 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -191,9 +191,15 @@ class ProcessRegistry: session._watch_disabled = True self.completion_queue.put({ "session_id": session.id, + "session_key": session.session_key, "command": session.command, "type": "watch_disabled", "suppressed": session._watch_suppressed, + "platform": session.watcher_platform, + "chat_id": session.watcher_chat_id, + "user_id": session.watcher_user_id, + "user_name": session.watcher_user_name, + "thread_id": session.watcher_thread_id, "message": ( f"Watch patterns disabled for process {session.id} — " f"too many matches ({session._watch_suppressed} suppressed). " @@ -219,11 +225,17 @@ class ProcessRegistry: self.completion_queue.put({ "session_id": session.id, + "session_key": session.session_key, "command": session.command, "type": "watch_match", "pattern": matched_pattern, "output": output, "suppressed": suppressed, + "platform": session.watcher_platform, + "chat_id": session.watcher_chat_id, + "user_id": session.watcher_user_id, + "user_name": session.watcher_user_name, + "thread_id": session.watcher_thread_id, }) @staticmethod From 2276b721410d81241bbf6053277f8d376c9b8633 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:52:30 +0530 Subject: [PATCH 179/849] fix: follow-up improvements for watch notification routing (#9537) - Populate watcher_* routing fields for watch-only processes (not just notify_on_complete), so watch-pattern events carry direct metadata instead of relying solely on session_key parsing fallback - Extract _parse_session_key() helper to dedupe session key parsing at two call sites in gateway/run.py - Add negative test proving cross-thread leakage doesn't happen - Add edge-case tests for _build_process_event_source returning None (empty evt, invalid platform, short session_key) - Add unit tests for _parse_session_key helper --- gateway/run.py | 36 +++++-- .../test_background_process_notifications.py | 96 ++++++++++++++++++- tools/terminal_tool.py | 32 ++++--- 3 files changed, 140 insertions(+), 24 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 670ec4c86..f9bf9a38b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -482,6 +482,23 @@ def _resolve_hermes_bin() -> Optional[list[str]]: return None +def _parse_session_key(session_key: str) -> "dict | None": + """Parse a session key into its component parts. + + Session keys follow the format ``agent:main:{platform}:{chat_type}:{chat_id}``. + Returns a dict with ``platform``, ``chat_type``, and ``chat_id`` keys, + or None if the key doesn't match the expected format. + """ + parts = session_key.split(":") + if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": + return { + "platform": parts[2], + "chat_type": parts[3], + "chat_id": parts[4], + } + return None + + def _format_gateway_process_notification(evt: dict) -> "str | None": """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" evt_type = evt.get("type", "completion") @@ -1489,12 +1506,11 @@ class GatewayRunner: notified: set = set() for session_key in active: # Parse platform + chat_id from the session key. - # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...] - parts = session_key.split(":") - if len(parts) < 5: + _parsed = _parse_session_key(session_key) + if not _parsed: continue - platform_str = parts[2] - chat_id = parts[4] + platform_str = _parsed["platform"] + chat_id = _parsed["chat_id"] # Deduplicate: one notification per chat, even if multiple # sessions (different users/threads) share the same chat. @@ -7479,11 +7495,11 @@ class GatewayRunner: exc, ) - parts = session_key.split(":") - if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": - derived_platform = parts[2] - derived_chat_type = parts[3] - derived_chat_id = parts[4] + _parsed = _parse_session_key(session_key) + if _parsed: + derived_platform = _parsed["platform"] + derived_chat_type = _parsed["chat_type"] + derived_chat_id = _parsed["chat_id"] platform_name = str(evt.get("platform") or derived_platform or "").strip().lower() chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 90e9e063a..68eb5e304 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, patch import pytest from gateway.config import GatewayConfig, Platform -from gateway.run import GatewayRunner +from gateway.run import GatewayRunner, _parse_session_key # --------------------------------------------------------------------------- @@ -302,3 +302,97 @@ def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypa assert source.thread_id == "42" assert source.user_id == "123" assert source.user_name == "Emiliyan" + + +@pytest.mark.asyncio +async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path): + """Negative test: watch notification must NOT route to the foreground thread.""" + from gateway.session import SessionSource + + runner = _build_runner(monkeypatch, tmp_path, "all") + adapter = runner.adapters[Platform.TELEGRAM] + + # Session store has the process's original thread (thread 42) + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="proc_owner", + user_name="alice", + ) + ) + + # The evt dict carries the correct session_key — NOT a foreground event + evt = { + "session_id": "proc_cross_thread", + "session_key": "agent:main:telegram:group:-100:42", + } + + await runner._inject_watch_notification("[SYSTEM: watch match]", evt) + + adapter.handle_message.assert_awaited_once() + synth_event = adapter.handle_message.await_args.args[0] + # Must route to thread 42 (process origin), NOT some other thread + assert synth_event.source.thread_id == "42" + assert synth_event.source.user_id == "proc_owner" + + +def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path): + """Missing session_key and no platform metadata → None (drop notification).""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + source = runner._build_process_event_source({"session_id": "proc_orphan"}) + assert source is None + + +def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path): + """Invalid platform string → None.""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_bad", + "platform": "not_a_real_platform", + "chat_type": "dm", + "chat_id": "123", + } + source = runner._build_process_event_source(evt) + assert source is None + + +def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path): + """Session key with <5 parts doesn't parse, falls through to empty metadata → None.""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_short", + "session_key": "agent:main:telegram", # Too few parts + } + source = runner._build_process_event_source(evt) + assert source is None + + +# --------------------------------------------------------------------------- +# _parse_session_key helper +# --------------------------------------------------------------------------- + +def test_parse_session_key_valid(): + result = _parse_session_key("agent:main:telegram:group:-100") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "-100"} + + +def test_parse_session_key_with_extra_parts(): + """Extra trailing parts (thread_id etc.) are ignored — only first 5 matter.""" + result = _parse_session_key("agent:main:discord:group:chan123:thread456") + assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"} + + +def test_parse_session_key_too_short(): + assert _parse_session_key("agent:main:telegram") is None + assert _parse_session_key("") is None + + +def test_parse_session_key_wrong_prefix(): + assert _parse_session_key("cron:main:telegram:dm:123") is None + assert _parse_session_key("agent:cron:telegram:dm:123") is None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 65f84e146..55f4c10a8 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1384,14 +1384,10 @@ def terminal_tool( if pty_disabled_reason: result_data["pty_note"] = pty_disabled_reason - # Mark for agent notification on completion - if notify_on_complete and background: - proc_session.notify_on_complete = True - result_data["notify_on_complete"] = True - - # In gateway mode, auto-register a fast watcher so the - # gateway can detect completion and trigger a new agent - # turn. CLI mode uses the completion_queue directly. + # Populate routing metadata on the session so that + # watch-pattern and completion notifications can be + # routed back to the correct chat/thread. + if background and (notify_on_complete or watch_patterns): from gateway.session_context import get_session_env as _gse _gw_platform = _gse("HERMES_SESSION_PLATFORM", "") if _gw_platform: @@ -1404,16 +1400,26 @@ def terminal_tool( proc_session.watcher_user_id = _gw_user_id proc_session.watcher_user_name = _gw_user_name proc_session.watcher_thread_id = _gw_thread_id + + # Mark for agent notification on completion + if notify_on_complete and background: + proc_session.notify_on_complete = True + result_data["notify_on_complete"] = True + + # In gateway mode, auto-register a fast watcher so the + # gateway can detect completion and trigger a new agent + # turn. CLI mode uses the completion_queue directly. + if proc_session.watcher_platform: proc_session.watcher_interval = 5 process_registry.pending_watchers.append({ "session_id": proc_session.id, "check_interval": 5, "session_key": session_key, - "platform": _gw_platform, - "chat_id": _gw_chat_id, - "user_id": _gw_user_id, - "user_name": _gw_user_name, - "thread_id": _gw_thread_id, + "platform": proc_session.watcher_platform, + "chat_id": proc_session.watcher_chat_id, + "user_id": proc_session.watcher_user_id, + "user_name": proc_session.watcher_user_name, + "thread_id": proc_session.watcher_thread_id, "notify_on_complete": True, }) From f61cc464f0a150313959d66dc9c87f27f18e0b8b Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 11:07:24 -0700 Subject: [PATCH 180/849] fix: include thread_id in _parse_session_key and fix stale parts reference _parse_session_key() now extracts the optional 6th part (thread_id) from session keys, and _notify_active_sessions_of_shutdown uses _parsed.get() instead of the removed 'parts' variable. Without this, shutdown notifications silently failed (NameError caught by try/except) and forum topic routing was lost. --- gateway/run.py | 14 +++++++++----- .../test_background_process_notifications.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index f9bf9a38b..327f8ae32 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -485,17 +485,21 @@ def _resolve_hermes_bin() -> Optional[list[str]]: def _parse_session_key(session_key: str) -> "dict | None": """Parse a session key into its component parts. - Session keys follow the format ``agent:main:{platform}:{chat_type}:{chat_id}``. - Returns a dict with ``platform``, ``chat_type``, and ``chat_id`` keys, - or None if the key doesn't match the expected format. + Session keys follow the format + ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``. + Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and + optionally ``thread_id`` keys, or None if the key doesn't match. """ parts = session_key.split(":") if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": - return { + result = { "platform": parts[2], "chat_type": parts[3], "chat_id": parts[4], } + if len(parts) > 5: + result["thread_id"] = parts[5] + return result return None @@ -1526,7 +1530,7 @@ class GatewayRunner: # Include thread_id if present so the message lands in the # correct forum topic / thread. - thread_id = parts[5] if len(parts) > 5 else None + thread_id = _parsed.get("thread_id") metadata = {"thread_id": thread_id} if thread_id else None await adapter.send(chat_id, msg, metadata=metadata) diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 68eb5e304..eabf92be6 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -383,9 +383,15 @@ def test_parse_session_key_valid(): def test_parse_session_key_with_extra_parts(): - """Extra trailing parts (thread_id etc.) are ignored — only first 5 matter.""" + """Thread ID (6th part) is extracted; further parts are ignored.""" result = _parse_session_key("agent:main:discord:group:chan123:thread456") - assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"} + assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"} + + +def test_parse_session_key_with_user_id_part(): + """7th part (user_id) is ignored — only up to thread_id is extracted.""" + result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"} def test_parse_session_key_too_short(): From 2dc5f9d2d387e345d1ac6e05766bcc99e3a115e0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:17:44 -0700 Subject: [PATCH 181/849] fix: light mode link/primary colors unreadable on white background (#10457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gold #FFD700 has 1.4:1 contrast ratio on white — barely visible. Replace with dark amber palette (#8B6508 primary, #7A5800 links) that passes WCAG AA (5.3:1 and 6.5:1 respectively). Changes: - :root primary palette → dark amber tones for light mode - Explicit light mode link colors (#7A5800 / #5A4100 hover) - Light mode sidebar active state with amber accent - Light mode table header/border styling - Footer hover color split by theme (gold for dark, amber for light) Dark mode is completely unchanged. Reported by @AbrahamMat7632 --- website/src/css/custom.css | 44 ++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/website/src/css/custom.css b/website/src/css/custom.css index cfc90c7f9..eda3ec1a7 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -8,20 +8,24 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); :root { - /* Gold/Amber palette from landing page */ - --ifm-color-primary: #FFD700; - --ifm-color-primary-dark: #E6C200; - --ifm-color-primary-darker: #D9B700; - --ifm-color-primary-darkest: #B39600; - --ifm-color-primary-light: #FFDD33; - --ifm-color-primary-lighter: #FFE14D; - --ifm-color-primary-lightest: #FFEB80; + /* Dark amber palette for light mode — readable on white (WCAG AA compliant) + Current gold #FFD700 has only 1.4:1 contrast on white; these tones pass 4.5:1+ */ + --ifm-color-primary: #8B6508; + --ifm-color-primary-dark: #7A5800; + --ifm-color-primary-darker: #6E4F00; + --ifm-color-primary-darkest: #5A4100; + --ifm-color-primary-light: #9E7410; + --ifm-color-primary-lighter: #B38319; + --ifm-color-primary-lightest: #C89222; --ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --ifm-font-family-monospace: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; --ifm-code-font-size: 90%; --ifm-heading-font-weight: 600; + + --ifm-link-color: #7A5800; + --ifm-link-hover-color: #5A4100; } /* Dark mode — the PRIMARY mode, matches landing page */ @@ -91,6 +95,13 @@ padding-left: calc(var(--ifm-menu-link-padding-horizontal) - 3px); } +/* Light mode sidebar active */ +[data-theme='light'] .menu__link--active:not(.menu__link--sublist) { + background-color: rgba(139, 101, 8, 0.08); + border-left: 3px solid #8B6508; + padding-left: calc(var(--ifm-menu-link-padding-horizontal) - 3px); +} + /* Code blocks */ [data-theme='dark'] .prism-code { background-color: #0a0a12 !important; @@ -167,6 +178,16 @@ pre.prism-code.language-ascii code { border-color: rgba(255, 215, 0, 0.06); } +/* Light mode table styling */ +[data-theme='light'] table th { + background-color: rgba(139, 101, 8, 0.06); + border-color: rgba(139, 101, 8, 0.15); +} + +[data-theme='light'] table td { + border-color: rgba(139, 101, 8, 0.10); +} + /* Footer */ .footer { border-top: 1px solid rgba(255, 215, 0, 0.08); @@ -177,11 +198,16 @@ pre.prism-code.language-ascii code { transition: color 0.2s; } -.footer a:hover { +[data-theme='dark'] .footer a:hover { color: #FFD700; text-decoration: none; } +[data-theme='light'] .footer a:hover { + color: #7A5800; + text-decoration: none; +} + /* Scrollbar */ [data-theme='dark'] ::-webkit-scrollbar { width: 8px; From d2f85383e874db37e0a3f924ff35e03434099aaf Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Wed, 15 Apr 2026 01:59:18 +0800 Subject: [PATCH 182/849] fix: change default OPENVIKING_ACCOUNT from root to default - Change default OPENVIKING_ACCOUNT from 'root' to 'default' - Add account and user config options to get_config_schema() - Add session creation in initialize() - Add reset_session() method - Update docstring to reflect new default This is a breaking change: existing users who relied on the 'root' account will need to either: 1. Set OPENVIKING_ACCOUNT=root in their environment, or 2. Migrate their data to the 'default' account Future release will add support for OPENVIKING_ACCOUNT and OPENVIKING_USER in setup when API key is provided. update desc for key setup --- plugins/memory/openviking/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 1777d423b..dad1d39b7 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -10,7 +10,7 @@ lifecycle instead of read-only search endpoints. Config via environment variables (profile-scoped via each profile's .env): OPENVIKING_ENDPOINT — Server URL (default: http://127.0.0.1:1933) OPENVIKING_API_KEY — API key (required for authenticated servers) - OPENVIKING_ACCOUNT — Tenant account (default: root) + OPENVIKING_ACCOUNT — Tenant account (default: default) OPENVIKING_USER — Tenant user (default: default) Capabilities: @@ -83,7 +83,7 @@ class _VikingClient: account: str = "", user: str = ""): self._endpoint = endpoint.rstrip("/") self._api_key = api_key - self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "root") + self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") self._user = user or os.environ.get("OPENVIKING_USER", "default") self._httpx = _get_httpx() if self._httpx is None: @@ -282,10 +282,22 @@ class OpenVikingMemoryProvider(MemoryProvider): }, { "key": "api_key", - "description": "OpenViking API key", + "description": "OpenViking API key (leave blank for local dev mode)", "secret": True, "env_var": "OPENVIKING_API_KEY", }, + { + "key": "account", + "description": "OpenViking tenant account ID (default, used when local mode, OPENVIKING_API_KEY is empty)", + "default": "default", + "env_var": "OPENVIKING_ACCOUNT", + }, + { + "key": "user", + "description": "OpenViking user ID within the account (default, used when local mode, OPENVIKING_API_KEY is empty)", + "default": "default", + "env_var": "OPENVIKING_USER", + }, ] def initialize(self, session_id: str, **kwargs) -> None: From 990030c26ed6c6bb1ae63784fc03adededf7b1cf Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Wed, 15 Apr 2026 02:33:56 +0800 Subject: [PATCH 183/849] feat: add contrib map --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 046255627..b875eb8a5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -197,6 +197,7 @@ AUTHOR_MAP = { "zhouboli@gmail.com": "zhouboli", "zqiao@microsoft.com": "tomqiaozc", "zzn+pa@zzn.im": "xinbenlv", + "zaynjarvis@gmail.com": "ZaynJarvis", } From 8b167af66bba0919e00ed20d55c208653e22815d Mon Sep 17 00:00:00 2001 From: Zayn Jarvis Date: Thu, 16 Apr 2026 01:35:40 +0800 Subject: [PATCH 184/849] feat: add ov agent header --- plugins/memory/openviking/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index dad1d39b7..0ae44fb41 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -85,6 +85,7 @@ class _VikingClient: self._api_key = api_key self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") self._user = user or os.environ.get("OPENVIKING_USER", "default") + self._agent = user or os.environ.get("OPENVIKING_AGENT", "hermes") self._httpx = _get_httpx() if self._httpx is None: raise ImportError("httpx is required for OpenViking: pip install httpx") @@ -94,6 +95,7 @@ class _VikingClient: "Content-Type": "application/json", "X-OpenViking-Account": self._account, "X-OpenViking-User": self._user, + "X-OpenViking-Agent": self._agent, } if self._api_key: h["X-API-Key"] = self._api_key @@ -288,16 +290,22 @@ class OpenVikingMemoryProvider(MemoryProvider): }, { "key": "account", - "description": "OpenViking tenant account ID (default, used when local mode, OPENVIKING_API_KEY is empty)", + "description": "OpenViking tenant account ID ([default], used when local mode, OPENVIKING_API_KEY is empty)", "default": "default", "env_var": "OPENVIKING_ACCOUNT", }, { "key": "user", - "description": "OpenViking user ID within the account (default, used when local mode, OPENVIKING_API_KEY is empty)", + "description": "OpenViking user ID within the account ([default], used when local mode, OPENVIKING_API_KEY is empty)", "default": "default", "env_var": "OPENVIKING_USER", }, + { + "key": "agent", + "description": "OpenViking agent ID within the account ([hermes], useful in multi-agent mode)", + "default": "hermes", + "env_var": "OPENVIKING_AGENT", + }, ] def initialize(self, session_id: str, **kwargs) -> None: From 0c30385be2460a3417511ae657c22d3bf95596d1 Mon Sep 17 00:00:00 2001 From: Zayn Jarvis Date: Thu, 16 Apr 2026 01:38:08 +0800 Subject: [PATCH 185/849] chore: update doc --- plugins/memory/openviking/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 0ae44fb41..afc96268c 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -12,6 +12,7 @@ Config via environment variables (profile-scoped via each profile's .env): OPENVIKING_API_KEY — API key (required for authenticated servers) OPENVIKING_ACCOUNT — Tenant account (default: default) OPENVIKING_USER — Tenant user (default: default) + OPENVIKING_AGENT — Tenant agent (default: hermes) Capabilities: - Automatic memory extraction on session commit (6 categories) From 5082a9f66ca7c2bbefc11f845e7b9fa628ac4cf5 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Thu, 16 Apr 2026 01:45:03 +0800 Subject: [PATCH 186/849] fix: wire agent/account/user params through _VikingClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix copy-paste bug: `self._agent = user` → `self._agent = agent` with new `agent` parameter in `_VikingClient.__init__` - Read account/user/agent env vars in `initialize()` and pass them to all 4 `_VikingClient` instantiations so identity headers are consistently applied across health check, prefetch, sync, and memory write paths Co-Authored-By: Claude Opus 4.6 --- plugins/memory/openviking/__init__.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index afc96268c..ec2d27d99 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -81,12 +81,12 @@ class _VikingClient: """Thin HTTP client for the OpenViking REST API.""" def __init__(self, endpoint: str, api_key: str = "", - account: str = "", user: str = ""): + account: str = "", user: str = "", agent: str = ""): self._endpoint = endpoint.rstrip("/") self._api_key = api_key self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") self._user = user or os.environ.get("OPENVIKING_USER", "default") - self._agent = user or os.environ.get("OPENVIKING_AGENT", "hermes") + self._agent = agent or os.environ.get("OPENVIKING_AGENT", "hermes") self._httpx = _get_httpx() if self._httpx is None: raise ImportError("httpx is required for OpenViking: pip install httpx") @@ -312,11 +312,17 @@ class OpenVikingMemoryProvider(MemoryProvider): def initialize(self, session_id: str, **kwargs) -> None: self._endpoint = os.environ.get("OPENVIKING_ENDPOINT", _DEFAULT_ENDPOINT) self._api_key = os.environ.get("OPENVIKING_API_KEY", "") + self._account = os.environ.get("OPENVIKING_ACCOUNT", "default") + self._user = os.environ.get("OPENVIKING_USER", "default") + self._agent = os.environ.get("OPENVIKING_AGENT", "hermes") self._session_id = session_id self._turn_count = 0 try: - self._client = _VikingClient(self._endpoint, self._api_key) + self._client = _VikingClient( + self._endpoint, self._api_key, + account=self._account, user=self._user, agent=self._agent, + ) if not self._client.health(): logger.warning("OpenViking server at %s is not reachable", self._endpoint) self._client = None @@ -372,7 +378,10 @@ class OpenVikingMemoryProvider(MemoryProvider): def _run(): try: - client = _VikingClient(self._endpoint, self._api_key) + client = _VikingClient( + self._endpoint, self._api_key, + account=self._account, user=self._user, agent=self._agent, + ) resp = client.post("/api/v1/search/find", { "query": query, "top_k": 5, @@ -407,7 +416,10 @@ class OpenVikingMemoryProvider(MemoryProvider): def _sync(): try: - client = _VikingClient(self._endpoint, self._api_key) + client = _VikingClient( + self._endpoint, self._api_key, + account=self._account, user=self._user, agent=self._agent, + ) sid = self._session_id # Add user message @@ -463,7 +475,10 @@ class OpenVikingMemoryProvider(MemoryProvider): def _write(): try: - client = _VikingClient(self._endpoint, self._api_key) + client = _VikingClient( + self._endpoint, self._api_key, + account=self._account, user=self._user, agent=self._agent, + ) # Add as a user message with memory context so the commit # picks it up as an explicit memory during extraction client.post(f"/api/v1/sessions/{self._session_id}/messages", { From f3ec4b3a16088d92beb3cccb1532a5007fe95959 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Tue, 14 Apr 2026 01:49:00 +0800 Subject: [PATCH 187/849] Fix OpenViking integration issues: explicit session creation, better error logging --- plugins/memory/openviking/__init__.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index ec2d27d99..72ec3b105 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -109,7 +109,12 @@ class _VikingClient: resp = self._httpx.get( self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - resp.raise_for_status() + try: + resp.raise_for_status() + except Exception as e: + logger.debug("OpenViking request failed: %s %s, status: %s, response: %s", + "GET", path, resp.status_code, resp.text) + raise return resp.json() def post(self, path: str, payload: dict = None, **kwargs) -> dict: @@ -117,7 +122,12 @@ class _VikingClient: self._url(path), json=payload or {}, headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - resp.raise_for_status() + try: + resp.raise_for_status() + except Exception as e: + logger.debug("OpenViking request failed: %s %s, status: %s, response: %s", + "POST", path, resp.status_code, resp.text) + raise return resp.json() def health(self) -> bool: @@ -326,6 +336,13 @@ class OpenVikingMemoryProvider(MemoryProvider): if not self._client.health(): logger.warning("OpenViking server at %s is not reachable", self._endpoint) self._client = None + else: + # Explicitly create the session to ensure it exists + try: + self._client.post("/api/v1/sessions", {"session_id": self._session_id}) + logger.info("OpenViking session %s created", self._session_id) + except Exception as e: + logger.debug("OpenViking session creation failed (may already exist): %s", e) except ImportError: logger.warning("httpx not installed — OpenViking plugin disabled") self._client = None @@ -352,7 +369,8 @@ class OpenVikingMemoryProvider(MemoryProvider): "(abstract/overview/full), viking_browse to explore.\n" "Use viking_remember to store facts, viking_add_resource to index URLs/docs." ) - except Exception: + except Exception as e: + logger.warning("OpenViking system_prompt_block failed: %s", e) return ( "# OpenViking Knowledge Base\n" f"Active. Endpoint: {self._endpoint}\n" From 7856d304f20303617453016ed9e818c729f6ee97 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 15 Apr 2026 23:14:32 +0800 Subject: [PATCH 188/849] fix(openviking): commit session on /new and context compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenViking memory provider extracts memories when its session is committed (POST /api/v1/sessions/{id}/commit). Before this fix, the CLI had two code paths that changed the active session_id without ever committing the outgoing OpenViking session: 1. /new (new_session() in cli.py) — called flush_memories() to write MEMORY.md, then immediately discarded the old session_id. The accumulated OpenViking session was never committed, so all context from that session was lost before extraction could run. 2. /compress and auto-compress (_compress_context() in run_agent.py) — split the SQLite session (new session_id) but left the OpenViking provider pointing at the old session_id with no commit, meaning all messages synced to OpenViking were silently orphaned. The gateway already handles session commit on /new and /reset via shutdown_memory_provider() on the cached agent; the CLI path did not. Fix: introduce a lightweight session-transition lifecycle alongside the existing full shutdown path: - OpenVikingMemoryProvider.reset_session(new_session_id): waits for in-flight background threads, resets per-session counters, and creates the new OV session via POST /api/v1/sessions — without tearing down the HTTP client (avoids connection overhead on /new). - MemoryManager.restart_session(new_session_id): calls reset_session() on providers that implement it; falls back to initialize() for providers that do not. Skips the builtin provider (no per-session state). - AIAgent.commit_memory_session(messages): wraps memory_manager.on_session_end() without shutdown — commits OV session for extraction but leaves the provider alive for the next session. - AIAgent.reinitialize_memory_session(new_session_id): wraps memory_manager.restart_session() — transitions all external providers to the new session after session_id has been assigned. Call sites: - cli.py new_session(): commit BEFORE session_id changes, reinitialize AFTER — ensuring OV extraction runs on the correct session and the new session is immediately ready for the next turn. - run_agent._compress_context(): same pattern, inside the if self._session_db: block where the session_id split happens. /compress and auto-compress are functionally identical at this layer: both call _compress_context(), so both are fixed by the same change. Tests added to tests/agent/test_memory_provider.py: - TestMemoryManagerRestartSession: reset_session() routing, builtin skip, initialize() fallback, failure tolerance, empty-manager noop. - TestOpenVikingResetSession: session_id update, per-session state clear, POST /api/v1/sessions call, API failure tolerance, no-client noop. Co-Authored-By: Claude Sonnet 4.6 --- agent/memory_manager.py | 22 ++++ cli.py | 14 +++ plugins/memory/openviking/__init__.py | 28 +++++ run_agent.py | 33 ++++++ tests/agent/test_memory_provider.py | 153 ++++++++++++++++++++++++++ 5 files changed, 250 insertions(+) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 6cd1c860b..8320710ce 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -281,6 +281,28 @@ class MemoryManager: provider.name, e, ) + def restart_session(self, new_session_id: str) -> None: + """Transition external providers to a new session without full teardown. + + Must be called AFTER on_session_end() has committed the old session. + Providers that implement reset_session() are transitioned cheaply + (HTTP client kept alive); others fall back to a full initialize(). + The builtin provider is skipped — it has no per-session state. + """ + for provider in self._providers: + if provider.name == "builtin": + continue + try: + if hasattr(provider, "reset_session"): + provider.reset_session(new_session_id) + else: + provider.initialize(session_id=new_session_id) + except Exception as e: + logger.debug( + "Memory provider '%s' restart_session failed: %s", + provider.name, e, + ) + def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Notify all providers before context compression. diff --git a/cli.py b/cli.py index 97698f133..94e56b0d5 100644 --- a/cli.py +++ b/cli.py @@ -4100,6 +4100,13 @@ class HermesCLI: self.agent.flush_memories(self.conversation_history) except (Exception, KeyboardInterrupt): pass + # Commit external memory providers (e.g. OpenViking) BEFORE + # session_id changes so extraction runs on the correct session. + if hasattr(self.agent, "commit_memory_session"): + try: + self.agent.commit_memory_session(self.conversation_history) + except Exception: + pass self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session @@ -4148,6 +4155,13 @@ class HermesCLI: ) except Exception: pass + # Reinitialize external memory providers with the new session_id + # so subsequent turns are tracked under the new session. + if hasattr(self.agent, "reinitialize_memory_session"): + try: + self.agent.reinitialize_memory_session(self.session_id) + except Exception: + pass self._notify_session_boundary("on_session_reset") if not silent: diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 72ec3b105..b1cb03b73 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -533,6 +533,34 @@ class OpenVikingMemoryProvider(MemoryProvider): except Exception as e: return tool_error(str(e)) + def reset_session(self, new_session_id: str) -> None: + """Transition to a new session without tearing down the HTTP client. + + Called by MemoryManager.restart_session() after on_session_end() has + committed the old session (e.g. after CLI /new or context compression). + Lighter than shutdown() + initialize(): keeps the httpx client alive, + resets per-session counters, and creates the new OV session. + """ + for t in (self._sync_thread, self._prefetch_thread): + if t and t.is_alive(): + t.join(timeout=5.0) + + self._session_id = new_session_id + self._turn_count = 0 + self._prefetch_result = "" + self._sync_thread = None + self._prefetch_thread = None + + if self._client: + try: + self._client.post("/api/v1/sessions", {"session_id": self._session_id}) + logger.info("OpenViking new session %s created", self._session_id) + except Exception as e: + logger.debug("OpenViking session creation on reset: %s", e) + + global _last_active_provider + _last_active_provider = self + def shutdown(self) -> None: # Wait for background threads to finish for t in (self._sync_thread, self._prefetch_thread): diff --git a/run_agent.py b/run_agent.py index efaeba829..773d22bed 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3040,6 +3040,34 @@ class AIAgent: except Exception: pass + def commit_memory_session(self, messages: list = None) -> None: + """Commit external memory providers for the current session. + + Calls on_session_end() WITHOUT shutting down providers — the session + data (e.g. OpenViking) is committed for extraction, but the HTTP + client and provider state remain alive for the next session. + Called before session_id changes (e.g. /new, context compression). + """ + if self._memory_manager: + try: + self._memory_manager.on_session_end(messages or []) + except Exception: + pass + + def reinitialize_memory_session(self, new_session_id: str) -> None: + """Transition memory providers to a new session after commit. + + Calls restart_session() which uses reset_session() on providers that + support it (cheap: keeps HTTP client, resets per-session counters) or + falls back to initialize() for providers that don't. + Called after session_id has been assigned (e.g. /new, compression). + """ + if self._memory_manager: + try: + self._memory_manager.restart_session(new_session_id) + except Exception: + pass + def close(self) -> None: """Release all resources held by this agent instance. @@ -6826,9 +6854,14 @@ class AIAgent: try: # Propagate title to the new session with auto-numbering old_title = self._session_db.get_session_title(self.session_id) + # Commit external memory (e.g. OpenViking) before session_id + # changes so extraction runs on the correct session. + self.commit_memory_session([]) self._session_db.end_session(self.session_id, "compression") old_session_id = self.session_id self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + # Transition external memory providers to the new session_id. + self.reinitialize_memory_session(self.session_id) # Update session_log_file to point to the new session's JSON file self.session_log_file = self.logs_dir / f"session_{self.session_id}.json" self._session_db.create_session( diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index fe04e0dd4..afd3dc002 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -695,3 +695,156 @@ class TestMemoryContextFencing: fence_end = combined.index("") assert "Alice" in combined[fence_start:fence_end] assert combined.index("weather") < fence_start + + +# --------------------------------------------------------------------------- +# MemoryManager.restart_session() tests +# --------------------------------------------------------------------------- + + +class ResettableProvider(FakeMemoryProvider): + """Provider that implements reset_session() for cheap session transitions.""" + + def __init__(self, name="resettable"): + super().__init__(name) + self.reset_session_calls = [] + + def reset_session(self, new_session_id: str) -> None: + self.reset_session_calls.append(new_session_id) + + +class TestMemoryManagerRestartSession: + def test_restart_calls_reset_session_on_external(self): + """restart_session() calls reset_session() on external providers that have it.""" + mgr = MemoryManager() + builtin = FakeMemoryProvider("builtin") + external = ResettableProvider("openviking") + mgr.add_provider(builtin) + mgr.add_provider(external) + + mgr.restart_session("new-session-123") + + assert external.reset_session_calls == ["new-session-123"] + # builtin is skipped — it has no per-session state + assert not hasattr(builtin, "reset_session_calls") + + def test_restart_skips_builtin(self): + """restart_session() does not call anything on the builtin provider.""" + mgr = MemoryManager() + builtin = ResettableProvider("builtin") + mgr.add_provider(builtin) + + mgr.restart_session("new-session-456") + + assert builtin.reset_session_calls == [] + + def test_restart_falls_back_to_initialize(self): + """restart_session() calls initialize() when provider has no reset_session().""" + mgr = MemoryManager() + builtin = FakeMemoryProvider("builtin") + external = FakeMemoryProvider("honcho") + mgr.add_provider(builtin) + mgr.add_provider(external) + + mgr.restart_session("fallback-session") + + assert external.initialized + assert external._init_kwargs["session_id"] == "fallback-session" + + def test_restart_tolerates_provider_failure(self): + """restart_session() swallows failures so other providers are still called.""" + mgr = MemoryManager() + builtin = FakeMemoryProvider("builtin") + bad = ResettableProvider("bad-provider") + + def _explode(new_sid): + raise RuntimeError("network error") + + bad.reset_session = _explode + good = ResettableProvider("good-provider") + # Register bad provider first, but only one external is allowed — + # so test both providers by using the fallback path. + mgr.add_provider(builtin) + mgr.add_provider(bad) + + # Calling restart_session should not raise even though the provider fails. + mgr.restart_session("safe-session") + + def test_restart_no_providers_is_noop(self): + """restart_session() on an empty manager does not raise.""" + mgr = MemoryManager() + mgr.restart_session("empty-session") # must not raise + + +# --------------------------------------------------------------------------- +# OpenVikingMemoryProvider.reset_session() tests +# --------------------------------------------------------------------------- + + +class TestOpenVikingResetSession: + """Unit tests for the cheap session-transition path in the OV plugin.""" + + def _make_provider(self): + """Return an OpenVikingMemoryProvider with a mock _client.""" + try: + from plugins.memory.openviking import OpenVikingMemoryProvider + except ImportError: + pytest.skip("openviking plugin not importable") + + provider = OpenVikingMemoryProvider() + provider._session_id = "old-session" + provider._turn_count = 5 + provider._prefetch_result = "cached result" + provider._sync_thread = None + provider._prefetch_thread = None + + mock_client = MagicMock() + mock_client.post.return_value = {} + provider._client = mock_client + return provider, mock_client + + def test_reset_updates_session_id(self): + provider, _ = self._make_provider() + provider.reset_session("new-session-abc") + assert provider._session_id == "new-session-abc" + + def test_reset_clears_per_session_state(self): + provider, _ = self._make_provider() + provider.reset_session("new-session-xyz") + assert provider._turn_count == 0 + assert provider._prefetch_result == "" + assert provider._sync_thread is None + assert provider._prefetch_thread is None + + def test_reset_creates_new_ov_session(self): + provider, mock_client = self._make_provider() + provider.reset_session("new-session-post") + mock_client.post.assert_called_once_with( + "/api/v1/sessions", {"session_id": "new-session-post"} + ) + + def test_reset_tolerates_ov_api_failure(self): + provider, mock_client = self._make_provider() + mock_client.post.side_effect = RuntimeError("connection refused") + # Must not raise — OV API failure is non-fatal for the reset path + provider.reset_session("no-server-session") + assert provider._session_id == "no-server-session" + + def test_reset_without_client_is_noop(self): + """reset_session() works even if provider was never initialized (no client).""" + try: + from plugins.memory.openviking import OpenVikingMemoryProvider + except ImportError: + pytest.skip("openviking plugin not importable") + + provider = OpenVikingMemoryProvider() + provider._client = None + provider._session_id = "old" + provider._turn_count = 3 + provider._sync_thread = None + provider._prefetch_thread = None + provider._prefetch_result = "" + + provider.reset_session("new-no-client") + assert provider._session_id == "new-no-client" + assert provider._turn_count == 0 From 8275fa597a702116a6a2cf1a9fa194d8874020ad Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Thu, 16 Apr 2026 00:31:48 +0800 Subject: [PATCH 189/849] refactor(memory): promote on_session_reset to base provider hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hasattr-forked OpenViking-specific paths with a proper base-class hook. Collapse the two agent wrappers into a single rotate_memory_session so callers don't orchestrate commit + rebind themselves. - MemoryProvider: add on_session_reset(new_session_id) as a default no-op - MemoryManager: on_session_reset fans out unconditionally (no hasattr, no builtin skip — base no-op covers it) - OpenViking: rename reset_session -> on_session_reset; drop the explicit POST /api/v1/sessions (OV auto-creates on first message) and the two debug raise_for_status wrappers - AIAgent: collapse commit_memory_session + reinitialize_memory_session into rotate_memory_session(new_sid, messages) - cli.py / run_agent.py: replace hasattr blocks and the split calls with a single unconditional rotate_memory_session call; compression path now passes the real messages list instead of [] - tests: align with on_session_reset, assert reset does NOT POST /sessions Co-Authored-By: Claude Opus 4.6 --- agent/memory_manager.py | 20 +++---- agent/memory_provider.py | 9 +++ cli.py | 22 ++----- plugins/memory/openviking/__init__.py | 39 ++---------- run_agent.py | 45 ++++---------- tests/agent/test_memory_provider.py | 85 +++++++++------------------ 6 files changed, 68 insertions(+), 152 deletions(-) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 8320710ce..b67724159 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -281,25 +281,19 @@ class MemoryManager: provider.name, e, ) - def restart_session(self, new_session_id: str) -> None: - """Transition external providers to a new session without full teardown. + def on_session_reset(self, new_session_id: str) -> None: + """Notify all providers of a session reset. - Must be called AFTER on_session_end() has committed the old session. - Providers that implement reset_session() are transitioned cheaply - (HTTP client kept alive); others fall back to a full initialize(). - The builtin provider is skipped — it has no per-session state. + Called after on_session_end() has committed the previous session. + Providers with per-session state override on_session_reset to rebind + it cheaply (default is a no-op on the base class). """ for provider in self._providers: - if provider.name == "builtin": - continue try: - if hasattr(provider, "reset_session"): - provider.reset_session(new_session_id) - else: - provider.initialize(session_id=new_session_id) + provider.on_session_reset(new_session_id) except Exception as e: logger.debug( - "Memory provider '%s' restart_session failed: %s", + "Memory provider '%s' on_session_reset failed: %s", provider.name, e, ) diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 24593e334..9c6f0225c 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -160,6 +160,15 @@ class MemoryProvider(ABC): (CLI exit, /reset, gateway session expiry). """ + def on_session_reset(self, new_session_id: str) -> None: + """Transition to a new session without full teardown. + + Called after on_session_end() has committed the previous session + (e.g. /new, context compression). Providers with per-session state + override to rebind counters/IDs while keeping HTTP clients alive. + Default: no-op. + """ + def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Called before context compression discards old messages. diff --git a/cli.py b/cli.py index 94e56b0d5..a00eaf970 100644 --- a/cli.py +++ b/cli.py @@ -4095,18 +4095,12 @@ class HermesCLI: def new_session(self, silent=False): """Start a fresh session with a new session ID and cleared agent state.""" - if self.agent and self.conversation_history: + old_history = self.conversation_history + if self.agent and old_history: try: - self.agent.flush_memories(self.conversation_history) + self.agent.flush_memories(old_history) except (Exception, KeyboardInterrupt): pass - # Commit external memory providers (e.g. OpenViking) BEFORE - # session_id changes so extraction runs on the correct session. - if hasattr(self.agent, "commit_memory_session"): - try: - self.agent.commit_memory_session(self.conversation_history) - except Exception: - pass self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session @@ -4155,13 +4149,9 @@ class HermesCLI: ) except Exception: pass - # Reinitialize external memory providers with the new session_id - # so subsequent turns are tracked under the new session. - if hasattr(self.agent, "reinitialize_memory_session"): - try: - self.agent.reinitialize_memory_session(self.session_id) - except Exception: - pass + # Commit the old session and rebind memory providers to the + # new session_id so subsequent turns are tracked correctly. + self.agent.rotate_memory_session(self.session_id, old_history) self._notify_session_boundary("on_session_reset") if not silent: diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index b1cb03b73..4251927cc 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -109,12 +109,7 @@ class _VikingClient: resp = self._httpx.get( self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - try: - resp.raise_for_status() - except Exception as e: - logger.debug("OpenViking request failed: %s %s, status: %s, response: %s", - "GET", path, resp.status_code, resp.text) - raise + resp.raise_for_status() return resp.json() def post(self, path: str, payload: dict = None, **kwargs) -> dict: @@ -122,12 +117,7 @@ class _VikingClient: self._url(path), json=payload or {}, headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - try: - resp.raise_for_status() - except Exception as e: - logger.debug("OpenViking request failed: %s %s, status: %s, response: %s", - "POST", path, resp.status_code, resp.text) - raise + resp.raise_for_status() return resp.json() def health(self) -> bool: @@ -336,13 +326,6 @@ class OpenVikingMemoryProvider(MemoryProvider): if not self._client.health(): logger.warning("OpenViking server at %s is not reachable", self._endpoint) self._client = None - else: - # Explicitly create the session to ensure it exists - try: - self._client.post("/api/v1/sessions", {"session_id": self._session_id}) - logger.info("OpenViking session %s created", self._session_id) - except Exception as e: - logger.debug("OpenViking session creation failed (may already exist): %s", e) except ImportError: logger.warning("httpx not installed — OpenViking plugin disabled") self._client = None @@ -533,14 +516,9 @@ class OpenVikingMemoryProvider(MemoryProvider): except Exception as e: return tool_error(str(e)) - def reset_session(self, new_session_id: str) -> None: - """Transition to a new session without tearing down the HTTP client. - - Called by MemoryManager.restart_session() after on_session_end() has - committed the old session (e.g. after CLI /new or context compression). - Lighter than shutdown() + initialize(): keeps the httpx client alive, - resets per-session counters, and creates the new OV session. - """ + def on_session_reset(self, new_session_id: str) -> None: + """Rebind per-session state to new_session_id. OV auto-creates the + session when the first message is added, so no create call here.""" for t in (self._sync_thread, self._prefetch_thread): if t and t.is_alive(): t.join(timeout=5.0) @@ -551,13 +529,6 @@ class OpenVikingMemoryProvider(MemoryProvider): self._sync_thread = None self._prefetch_thread = None - if self._client: - try: - self._client.post("/api/v1/sessions", {"session_id": self._session_id}) - logger.info("OpenViking new session %s created", self._session_id) - except Exception as e: - logger.debug("OpenViking session creation on reset: %s", e) - global _last_active_provider _last_active_provider = self diff --git a/run_agent.py b/run_agent.py index 773d22bed..a19857bc4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3040,33 +3040,17 @@ class AIAgent: except Exception: pass - def commit_memory_session(self, messages: list = None) -> None: - """Commit external memory providers for the current session. - - Calls on_session_end() WITHOUT shutting down providers — the session - data (e.g. OpenViking) is committed for extraction, but the HTTP - client and provider state remain alive for the next session. - Called before session_id changes (e.g. /new, context compression). - """ - if self._memory_manager: - try: - self._memory_manager.on_session_end(messages or []) - except Exception: - pass - - def reinitialize_memory_session(self, new_session_id: str) -> None: - """Transition memory providers to a new session after commit. - - Calls restart_session() which uses reset_session() on providers that - support it (cheap: keeps HTTP client, resets per-session counters) or - falls back to initialize() for providers that don't. - Called after session_id has been assigned (e.g. /new, compression). - """ - if self._memory_manager: - try: - self._memory_manager.restart_session(new_session_id) - except Exception: - pass + def rotate_memory_session(self, new_session_id: str, messages: list = None) -> None: + """Commit the current memory session, then rebind providers to + new_session_id. Keeps HTTP clients/state alive across the transition. + Called when session_id rotates (e.g. /new, context compression).""" + if not self._memory_manager: + return + try: + self._memory_manager.on_session_end(messages or []) + self._memory_manager.on_session_reset(new_session_id) + except Exception: + pass def close(self) -> None: """Release all resources held by this agent instance. @@ -6854,14 +6838,11 @@ class AIAgent: try: # Propagate title to the new session with auto-numbering old_title = self._session_db.get_session_title(self.session_id) - # Commit external memory (e.g. OpenViking) before session_id - # changes so extraction runs on the correct session. - self.commit_memory_session([]) self._session_db.end_session(self.session_id, "compression") old_session_id = self.session_id self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Transition external memory providers to the new session_id. - self.reinitialize_memory_session(self.session_id) + # Commit the old memory session and rebind providers to the new one. + self.rotate_memory_session(self.session_id, messages) # Update session_log_file to point to the new session's JSON file self.session_log_file = self.logs_dir / f"session_{self.session_id}.json" self._session_db.create_session( diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index afd3dc002..dc7f4b032 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -698,61 +698,47 @@ class TestMemoryContextFencing: # --------------------------------------------------------------------------- -# MemoryManager.restart_session() tests +# MemoryManager.on_session_reset() tests # --------------------------------------------------------------------------- class ResettableProvider(FakeMemoryProvider): - """Provider that implements reset_session() for cheap session transitions.""" + """Provider that records on_session_reset calls for assertions.""" def __init__(self, name="resettable"): super().__init__(name) self.reset_session_calls = [] - def reset_session(self, new_session_id: str) -> None: + def on_session_reset(self, new_session_id: str) -> None: self.reset_session_calls.append(new_session_id) -class TestMemoryManagerRestartSession: - def test_restart_calls_reset_session_on_external(self): - """restart_session() calls reset_session() on external providers that have it.""" +class TestMemoryManagerOnSessionReset: + def test_fans_out_to_all_providers(self): mgr = MemoryManager() - builtin = FakeMemoryProvider("builtin") + builtin = ResettableProvider("builtin") external = ResettableProvider("openviking") mgr.add_provider(builtin) mgr.add_provider(external) - mgr.restart_session("new-session-123") + mgr.on_session_reset("new-session-123") + assert builtin.reset_session_calls == ["new-session-123"] assert external.reset_session_calls == ["new-session-123"] - # builtin is skipped — it has no per-session state - assert not hasattr(builtin, "reset_session_calls") - def test_restart_skips_builtin(self): - """restart_session() does not call anything on the builtin provider.""" - mgr = MemoryManager() - builtin = ResettableProvider("builtin") - mgr.add_provider(builtin) - - mgr.restart_session("new-session-456") - - assert builtin.reset_session_calls == [] - - def test_restart_falls_back_to_initialize(self): - """restart_session() calls initialize() when provider has no reset_session().""" + def test_base_default_is_noop(self): + """Providers that don't override on_session_reset get the base no-op.""" mgr = MemoryManager() builtin = FakeMemoryProvider("builtin") external = FakeMemoryProvider("honcho") mgr.add_provider(builtin) mgr.add_provider(external) - mgr.restart_session("fallback-session") + # Must not raise — default is a no-op + mgr.on_session_reset("noop-session") + assert not external.initialized - assert external.initialized - assert external._init_kwargs["session_id"] == "fallback-session" - - def test_restart_tolerates_provider_failure(self): - """restart_session() swallows failures so other providers are still called.""" + def test_tolerates_provider_failure(self): mgr = MemoryManager() builtin = FakeMemoryProvider("builtin") bad = ResettableProvider("bad-provider") @@ -760,32 +746,26 @@ class TestMemoryManagerRestartSession: def _explode(new_sid): raise RuntimeError("network error") - bad.reset_session = _explode - good = ResettableProvider("good-provider") - # Register bad provider first, but only one external is allowed — - # so test both providers by using the fallback path. + bad.on_session_reset = _explode mgr.add_provider(builtin) mgr.add_provider(bad) - # Calling restart_session should not raise even though the provider fails. - mgr.restart_session("safe-session") + mgr.on_session_reset("safe-session") # must not raise - def test_restart_no_providers_is_noop(self): - """restart_session() on an empty manager does not raise.""" + def test_no_providers_is_noop(self): mgr = MemoryManager() - mgr.restart_session("empty-session") # must not raise + mgr.on_session_reset("empty-session") # must not raise # --------------------------------------------------------------------------- -# OpenVikingMemoryProvider.reset_session() tests +# OpenVikingMemoryProvider.on_session_reset() tests # --------------------------------------------------------------------------- -class TestOpenVikingResetSession: +class TestOpenVikingOnSessionReset: """Unit tests for the cheap session-transition path in the OV plugin.""" def _make_provider(self): - """Return an OpenVikingMemoryProvider with a mock _client.""" try: from plugins.memory.openviking import OpenVikingMemoryProvider except ImportError: @@ -805,33 +785,24 @@ class TestOpenVikingResetSession: def test_reset_updates_session_id(self): provider, _ = self._make_provider() - provider.reset_session("new-session-abc") + provider.on_session_reset("new-session-abc") assert provider._session_id == "new-session-abc" def test_reset_clears_per_session_state(self): provider, _ = self._make_provider() - provider.reset_session("new-session-xyz") + provider.on_session_reset("new-session-xyz") assert provider._turn_count == 0 assert provider._prefetch_result == "" assert provider._sync_thread is None assert provider._prefetch_thread is None - def test_reset_creates_new_ov_session(self): + def test_reset_does_not_create_ov_session(self): + """OV auto-creates on first message; reset must not POST /sessions.""" provider, mock_client = self._make_provider() - provider.reset_session("new-session-post") - mock_client.post.assert_called_once_with( - "/api/v1/sessions", {"session_id": "new-session-post"} - ) + provider.on_session_reset("new-session-post") + mock_client.post.assert_not_called() - def test_reset_tolerates_ov_api_failure(self): - provider, mock_client = self._make_provider() - mock_client.post.side_effect = RuntimeError("connection refused") - # Must not raise — OV API failure is non-fatal for the reset path - provider.reset_session("no-server-session") - assert provider._session_id == "no-server-session" - - def test_reset_without_client_is_noop(self): - """reset_session() works even if provider was never initialized (no client).""" + def test_reset_without_client_is_safe(self): try: from plugins.memory.openviking import OpenVikingMemoryProvider except ImportError: @@ -845,6 +816,6 @@ class TestOpenVikingResetSession: provider._prefetch_thread = None provider._prefetch_result = "" - provider.reset_session("new-no-client") + provider.on_session_reset("new-no-client") assert provider._session_id == "new-no-client" assert provider._turn_count == 0 From 7cb06e3bb3b4954277f993fa388f81e55f428202 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Thu, 16 Apr 2026 00:38:19 +0800 Subject: [PATCH 190/849] =?UTF-8?q?refactor(memory):=20drop=20on=5Fsession?= =?UTF-8?q?=5Freset=20=E2=80=94=20commit-only=20is=20enough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OV transparently handles message history across /new and /compress: old messages stay in the same session and extraction is idempotent, so there's no need to rebind providers to a new session_id. The only thing the session boundary actually needs is to trigger extraction. - MemoryProvider / MemoryManager: remove on_session_reset hook - OpenViking: remove on_session_reset override (nothing to do) - AIAgent: replace rotate_memory_session with commit_memory_session (just calls on_session_end, no rebind) - cli.py / run_agent.py: single commit_memory_session call at the session boundary before session_id rotates - tests: replace on_session_reset coverage with routing tests for MemoryManager.on_session_end Co-Authored-By: Claude Opus 4.6 --- agent/memory_manager.py | 16 ---- agent/memory_provider.py | 9 -- cli.py | 10 +-- plugins/memory/openviking/__init__.py | 16 ---- run_agent.py | 14 +-- tests/agent/test_memory_provider.py | 121 ++++---------------------- 6 files changed, 30 insertions(+), 156 deletions(-) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index b67724159..6cd1c860b 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -281,22 +281,6 @@ class MemoryManager: provider.name, e, ) - def on_session_reset(self, new_session_id: str) -> None: - """Notify all providers of a session reset. - - Called after on_session_end() has committed the previous session. - Providers with per-session state override on_session_reset to rebind - it cheaply (default is a no-op on the base class). - """ - for provider in self._providers: - try: - provider.on_session_reset(new_session_id) - except Exception as e: - logger.debug( - "Memory provider '%s' on_session_reset failed: %s", - provider.name, e, - ) - def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Notify all providers before context compression. diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 9c6f0225c..24593e334 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -160,15 +160,6 @@ class MemoryProvider(ABC): (CLI exit, /reset, gateway session expiry). """ - def on_session_reset(self, new_session_id: str) -> None: - """Transition to a new session without full teardown. - - Called after on_session_end() has committed the previous session - (e.g. /new, context compression). Providers with per-session state - override to rebind counters/IDs while keeping HTTP clients alive. - Default: no-op. - """ - def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: """Called before context compression discards old messages. diff --git a/cli.py b/cli.py index a00eaf970..fbc8f8525 100644 --- a/cli.py +++ b/cli.py @@ -4095,12 +4095,13 @@ class HermesCLI: def new_session(self, silent=False): """Start a fresh session with a new session ID and cleared agent state.""" - old_history = self.conversation_history - if self.agent and old_history: + if self.agent and self.conversation_history: try: - self.agent.flush_memories(old_history) + self.agent.flush_memories(self.conversation_history) except (Exception, KeyboardInterrupt): pass + # Trigger memory extraction on the old session before session_id rotates. + self.agent.commit_memory_session(self.conversation_history) self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session @@ -4149,9 +4150,6 @@ class HermesCLI: ) except Exception: pass - # Commit the old session and rebind memory providers to the - # new session_id so subsequent turns are tracked correctly. - self.agent.rotate_memory_session(self.session_id, old_history) self._notify_session_boundary("on_session_reset") if not silent: diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 4251927cc..86d7ad5ef 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -516,22 +516,6 @@ class OpenVikingMemoryProvider(MemoryProvider): except Exception as e: return tool_error(str(e)) - def on_session_reset(self, new_session_id: str) -> None: - """Rebind per-session state to new_session_id. OV auto-creates the - session when the first message is added, so no create call here.""" - for t in (self._sync_thread, self._prefetch_thread): - if t and t.is_alive(): - t.join(timeout=5.0) - - self._session_id = new_session_id - self._turn_count = 0 - self._prefetch_result = "" - self._sync_thread = None - self._prefetch_thread = None - - global _last_active_provider - _last_active_provider = self - def shutdown(self) -> None: # Wait for background threads to finish for t in (self._sync_thread, self._prefetch_thread): diff --git a/run_agent.py b/run_agent.py index a19857bc4..d7d1249be 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3040,15 +3040,15 @@ class AIAgent: except Exception: pass - def rotate_memory_session(self, new_session_id: str, messages: list = None) -> None: - """Commit the current memory session, then rebind providers to - new_session_id. Keeps HTTP clients/state alive across the transition. - Called when session_id rotates (e.g. /new, context compression).""" + def commit_memory_session(self, messages: list = None) -> None: + """Trigger end-of-session extraction without tearing providers down. + Called when session_id rotates (e.g. /new, context compression); + providers keep their state and continue running under the old + session_id — they just flush pending extraction now.""" if not self._memory_manager: return try: self._memory_manager.on_session_end(messages or []) - self._memory_manager.on_session_reset(new_session_id) except Exception: pass @@ -6838,11 +6838,11 @@ class AIAgent: try: # Propagate title to the new session with auto-numbering old_title = self._session_db.get_session_title(self.session_id) + # Trigger memory extraction on the old session before it rotates. + self.commit_memory_session(messages) self._session_db.end_session(self.session_id, "compression") old_session_id = self.session_id self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Commit the old memory session and rebind providers to the new one. - self.rotate_memory_session(self.session_id, messages) # Update session_log_file to point to the new session's JSON file self.session_log_file = self.logs_dir / f"session_{self.session_id}.json" self._session_db.create_session( diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index dc7f4b032..505f40bd5 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -698,124 +698,41 @@ class TestMemoryContextFencing: # --------------------------------------------------------------------------- -# MemoryManager.on_session_reset() tests +# AIAgent.commit_memory_session — routes to MemoryManager.on_session_end # --------------------------------------------------------------------------- -class ResettableProvider(FakeMemoryProvider): - """Provider that records on_session_reset calls for assertions.""" +class _CommitRecorder(FakeMemoryProvider): + """Provider that records on_session_end calls for assertions.""" - def __init__(self, name="resettable"): + def __init__(self, name="recorder"): super().__init__(name) - self.reset_session_calls = [] + self.end_calls = [] - def on_session_reset(self, new_session_id: str) -> None: - self.reset_session_calls.append(new_session_id) + def on_session_end(self, messages): + self.end_calls.append(list(messages or [])) -class TestMemoryManagerOnSessionReset: - def test_fans_out_to_all_providers(self): +class TestCommitMemorySessionRouting: + def test_on_session_end_fans_out(self): mgr = MemoryManager() - builtin = ResettableProvider("builtin") - external = ResettableProvider("openviking") + builtin = _CommitRecorder("builtin") + external = _CommitRecorder("openviking") mgr.add_provider(builtin) mgr.add_provider(external) - mgr.on_session_reset("new-session-123") + msgs = [{"role": "user", "content": "hi"}] + mgr.on_session_end(msgs) - assert builtin.reset_session_calls == ["new-session-123"] - assert external.reset_session_calls == ["new-session-123"] + assert builtin.end_calls == [msgs] + assert external.end_calls == [msgs] - def test_base_default_is_noop(self): - """Providers that don't override on_session_reset get the base no-op.""" + def test_on_session_end_tolerates_failure(self): mgr = MemoryManager() builtin = FakeMemoryProvider("builtin") - external = FakeMemoryProvider("honcho") - mgr.add_provider(builtin) - mgr.add_provider(external) - - # Must not raise — default is a no-op - mgr.on_session_reset("noop-session") - assert not external.initialized - - def test_tolerates_provider_failure(self): - mgr = MemoryManager() - builtin = FakeMemoryProvider("builtin") - bad = ResettableProvider("bad-provider") - - def _explode(new_sid): - raise RuntimeError("network error") - - bad.on_session_reset = _explode + bad = _CommitRecorder("bad-provider") + bad.on_session_end = lambda m: (_ for _ in ()).throw(RuntimeError("boom")) mgr.add_provider(builtin) mgr.add_provider(bad) - mgr.on_session_reset("safe-session") # must not raise - - def test_no_providers_is_noop(self): - mgr = MemoryManager() - mgr.on_session_reset("empty-session") # must not raise - - -# --------------------------------------------------------------------------- -# OpenVikingMemoryProvider.on_session_reset() tests -# --------------------------------------------------------------------------- - - -class TestOpenVikingOnSessionReset: - """Unit tests for the cheap session-transition path in the OV plugin.""" - - def _make_provider(self): - try: - from plugins.memory.openviking import OpenVikingMemoryProvider - except ImportError: - pytest.skip("openviking plugin not importable") - - provider = OpenVikingMemoryProvider() - provider._session_id = "old-session" - provider._turn_count = 5 - provider._prefetch_result = "cached result" - provider._sync_thread = None - provider._prefetch_thread = None - - mock_client = MagicMock() - mock_client.post.return_value = {} - provider._client = mock_client - return provider, mock_client - - def test_reset_updates_session_id(self): - provider, _ = self._make_provider() - provider.on_session_reset("new-session-abc") - assert provider._session_id == "new-session-abc" - - def test_reset_clears_per_session_state(self): - provider, _ = self._make_provider() - provider.on_session_reset("new-session-xyz") - assert provider._turn_count == 0 - assert provider._prefetch_result == "" - assert provider._sync_thread is None - assert provider._prefetch_thread is None - - def test_reset_does_not_create_ov_session(self): - """OV auto-creates on first message; reset must not POST /sessions.""" - provider, mock_client = self._make_provider() - provider.on_session_reset("new-session-post") - mock_client.post.assert_not_called() - - def test_reset_without_client_is_safe(self): - try: - from plugins.memory.openviking import OpenVikingMemoryProvider - except ImportError: - pytest.skip("openviking plugin not importable") - - provider = OpenVikingMemoryProvider() - provider._client = None - provider._session_id = "old" - provider._turn_count = 3 - provider._sync_thread = None - provider._prefetch_thread = None - provider._prefetch_result = "" - - provider.on_session_reset("new-no-client") - assert provider._session_id == "new-no-client" - assert provider._turn_count == 0 + mgr.on_session_end([]) # must not raise From d1d425e9d0e0e37bc0855fe5a4142bac86a73b0d Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 11:08:14 -0700 Subject: [PATCH 191/849] chore: add ZaynJarvis bytedance email to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b875eb8a5..73d663e55 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -198,6 +198,7 @@ AUTHOR_MAP = { "zqiao@microsoft.com": "tomqiaozc", "zzn+pa@zzn.im": "xinbenlv", "zaynjarvis@gmail.com": "ZaynJarvis", + "zhiheng.liu@bytedance.com": "ZaynJarvis", } From 4b4b4d47bcbf30a7ca62ab31aa1802a603773a8a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 14:14:01 -0500 Subject: [PATCH 192/849] 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 73ba81272..e6b10df89 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 e172cabc2..e3fb58513 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 86489e334..be27d5347 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 000000000..39beef908 --- /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 08b415276..98e6149b1 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 6afd5c094..86bacdecb 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 eb8fd7eb5..55dbac86f 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 549e85fe2..19756e8d3 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 d20e25292..c92777311 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 000000000..51fe3e821 --- /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 bb5769f3a..fff364689 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 46bd330c1..728a8fcce 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 3ba0114ab..865ab8579 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 54e6733f8..b2891661a 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 b97c6dd7a..5aeb23878 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 000000000..80b52078c --- /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 cfb91b059..fbbd37ccb 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 7d0717c7a..afd00e4a2 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 000000000..2f40b33c9 --- /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 24f931770..70dbb536f 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 502aab8fb..8bfa7fe20 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 90eef0c63..317d33c97 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 000000000..ffc9a864f --- /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 193/849] 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 956093748..8731cbaa0 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 39beef908..000000000 --- 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 98e6149b1..549314abd 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 51fe3e821..000000000 --- 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 80b52078c..000000000 --- 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 afd00e4a2..27bf5e073 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 70dbb536f..c6ba28c80 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 ffc9a864f..000000000 --- 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 6391b46779e4457103a841714104207a4b8e6ac9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:16:28 -0700 Subject: [PATCH 194/849] fix: bound auxiliary client cache to prevent fd exhaustion in long-running gateways (#10200) (#10470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _client_cache used event loop id() as part of the cache key, so every new worker-thread event loop created a new entry for the same provider config. In long-running gateways where threads are recycled frequently, this caused unbounded cache growth — each stale entry held an unclosed AsyncOpenAI client with its httpx connection pool, eventually exhausting file descriptors. Fix: remove loop_id from the cache key and instead validate on each async cache hit that the cached loop is the current, open loop. If the loop changed or was closed, the stale entry is replaced in-place rather than creating an additional entry. This bounds cache growth to at most one entry per unique provider config. Also adds a _CLIENT_CACHE_MAX_SIZE (64) safety belt with FIFO eviction as defense-in-depth against any remaining unbounded growth. Cross-loop safety is preserved: different event loops still get different client instances (validated by existing test suite). Closes #10200 --- agent/auxiliary_client.py | 58 +++++--- .../run_agent/test_async_httpx_del_neuter.py | 134 +++++++++++++++++- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4d2331548..479776428 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1835,9 +1835,15 @@ def auxiliary_max_tokens_param(value: int) -> dict: # Every auxiliary LLM consumer should use these instead of manually # constructing clients and calling .chat.completions.create(). -# Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model) +# Client cache: (provider, async_mode, base_url, api_key, api_mode, runtime_key) -> (client, default_model, loop) +# NOTE: loop identity is NOT part of the key. On async cache hits we check +# whether the cached loop is the *current* loop; if not, the stale entry is +# replaced in-place. This bounds cache growth to one entry per unique +# provider config rather than one per (config × event-loop), which previously +# caused unbounded fd accumulation in long-running gateway processes (#10200). _client_cache: Dict[tuple, tuple] = {} _client_cache_lock = threading.Lock() +_CLIENT_CACHE_MAX_SIZE = 64 # safety belt — evict oldest when exceeded def neuter_async_httpx_del() -> None: @@ -1970,39 +1976,49 @@ def _get_cached_client( Async clients (AsyncOpenAI) use httpx.AsyncClient internally, which binds to the event loop that was current when the client was created. Using such a client on a *different* loop causes deadlocks or - RuntimeError. To prevent cross-loop issues (especially in gateway - mode where _run_async() may spawn fresh loops in worker threads), the - cache key for async clients includes the current event loop's identity - so each loop gets its own client instance. + RuntimeError. To prevent cross-loop issues, the cache validates on + every async hit that the cached loop is the *current, open* loop. + If the loop changed (e.g. a new gateway worker-thread loop), the stale + entry is replaced in-place rather than creating an additional entry. + + This keeps cache size bounded to one entry per unique provider config, + preventing the fd-exhaustion that previously occurred in long-running + gateways where recycled worker threads created unbounded entries (#10200). """ - # Include loop identity for async clients to prevent cross-loop reuse. - # httpx.AsyncClient (inside AsyncOpenAI) is bound to the loop where it - # was created — reusing it on a different loop causes deadlocks (#2681). - loop_id = 0 + # Resolve the current event loop for async clients so we can validate + # cached entries. Loop identity is NOT in the cache key — instead we + # check at hit time whether the cached loop is still current and open. + # This prevents unbounded cache growth from recycled worker-thread loops + # while still guaranteeing we never reuse a client on the wrong loop + # (which causes deadlocks, see #2681). current_loop = None if async_mode: try: import asyncio as _aio current_loop = _aio.get_event_loop() - loop_id = id(current_loop) except RuntimeError: pass runtime = _normalize_main_runtime(main_runtime) runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else () - cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id, runtime_key) + cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key) with _client_cache_lock: if cache_key in _client_cache: cached_client, cached_default, cached_loop = _client_cache[cache_key] if async_mode: - # A cached async client whose loop has been closed will raise - # "Event loop is closed" when httpx tries to clean up its - # transport. Discard the stale client and create a fresh one. - if cached_loop is not None and cached_loop.is_closed(): - _force_close_async_httpx(cached_client) - del _client_cache[cache_key] - else: + # Validate: the cached client must be bound to the CURRENT, + # OPEN loop. If the loop changed or was closed, the httpx + # transport inside is dead — force-close and replace. + loop_ok = ( + cached_loop is not None + and cached_loop is current_loop + and not cached_loop.is_closed() + ) + if loop_ok: effective = _compat_model(cached_client, model, cached_default) return cached_client, effective + # Stale — evict and fall through to create a new client. + _force_close_async_httpx(cached_client) + del _client_cache[cache_key] else: effective = _compat_model(cached_client, model, cached_default) return cached_client, effective @@ -2022,6 +2038,12 @@ def _get_cached_client( bound_loop = current_loop with _client_cache_lock: if cache_key not in _client_cache: + # Safety belt: if the cache has grown beyond the max, evict + # the oldest entries (FIFO — dict preserves insertion order). + while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: + evict_key, evict_entry = next(iter(_client_cache.items())) + _force_close_async_httpx(evict_entry[0]) + del _client_cache[evict_key] _client_cache[cache_key] = (client, default_model, bound_loop) else: client, default_model, _ = _client_cache[cache_key] diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py index ce8e20e70..960df7084 100644 --- a/tests/run_agent/test_async_httpx_del_neuter.py +++ b/tests/run_agent/test_async_httpx_del_neuter.py @@ -103,7 +103,7 @@ class TestCleanupStaleAsyncClients: mock_client._client = MagicMock() mock_client._client.is_closed = False - key = ("test_stale", True, "", "", id(loop)) + key = ("test_stale", True, "", "", "", ()) with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", loop) @@ -127,7 +127,7 @@ class TestCleanupStaleAsyncClients: loop = asyncio.new_event_loop() # NOT closed mock_client = MagicMock() - key = ("test_live", True, "", "", id(loop)) + key = ("test_live", True, "", "", "", ()) with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", loop) @@ -149,7 +149,7 @@ class TestCleanupStaleAsyncClients: ) mock_client = MagicMock() - key = ("test_sync", False, "", "", 0) + key = ("test_sync", False, "", "", "", ()) with _client_cache_lock: _client_cache[key] = (mock_client, "test-model", None) @@ -160,3 +160,131 @@ class TestCleanupStaleAsyncClients: finally: with _client_cache_lock: _client_cache.pop(key, None) + + +# --------------------------------------------------------------------------- +# Cache bounded growth (#10200) +# --------------------------------------------------------------------------- + +class TestClientCacheBoundedGrowth: + """Verify the cache stays bounded when loops change (fix for #10200). + + Previously, loop_id was part of the cache key, so every new event loop + created a new entry for the same provider config. Now loop identity is + validated at hit time and stale entries are replaced in-place. + """ + + def test_same_key_replaces_stale_loop_entry(self): + """When the loop changes, the old entry should be replaced, not duplicated.""" + from agent.auxiliary_client import ( + _client_cache, + _client_cache_lock, + _get_cached_client, + ) + + key = ("test_replace", True, "", "", "", ()) + + # Simulate a stale entry from a closed loop + old_loop = asyncio.new_event_loop() + old_loop.close() + old_client = MagicMock() + old_client._client = MagicMock() + old_client._client.is_closed = False + + with _client_cache_lock: + _client_cache[key] = (old_client, "old-model", old_loop) + + try: + # Now call _get_cached_client — should detect stale loop and evict + with patch("agent.auxiliary_client.resolve_provider_client") as mock_resolve: + mock_resolve.return_value = (MagicMock(), "new-model") + client, model = _get_cached_client( + "test_replace", async_mode=True, + ) + # The old entry should have been replaced + with _client_cache_lock: + assert key in _client_cache, "Key should still exist (replaced)" + entry = _client_cache[key] + assert entry[1] == "new-model", "Should have the new model" + finally: + with _client_cache_lock: + _client_cache.pop(key, None) + + def test_different_loops_do_not_grow_cache(self): + """Multiple event loops for the same provider should NOT create multiple entries.""" + from agent.auxiliary_client import ( + _client_cache, + _client_cache_lock, + ) + + key = ("test_no_grow", True, "", "", "", ()) + + loops = [] + try: + for i in range(5): + loop = asyncio.new_event_loop() + loops.append(loop) + mock_client = MagicMock() + mock_client._client = MagicMock() + mock_client._client.is_closed = False + + # Close previous loop entries (simulating worker thread recycling) + if i > 0: + loops[i - 1].close() + + with _client_cache_lock: + # Simulate what _get_cached_client does: replace on loop mismatch + if key in _client_cache: + old_entry = _client_cache[key] + del _client_cache[key] + _client_cache[key] = (mock_client, f"model-{i}", loop) + + # Only one entry should exist for this key + with _client_cache_lock: + count = sum(1 for k in _client_cache if k == key) + assert count == 1, f"Expected 1 entry, got {count}" + finally: + for loop in loops: + if not loop.is_closed(): + loop.close() + with _client_cache_lock: + _client_cache.pop(key, None) + + def test_max_cache_size_eviction(self): + """Cache should not exceed _CLIENT_CACHE_MAX_SIZE.""" + from agent.auxiliary_client import ( + _client_cache, + _client_cache_lock, + _CLIENT_CACHE_MAX_SIZE, + ) + + # Save existing cache state + with _client_cache_lock: + saved = dict(_client_cache) + _client_cache.clear() + + try: + # Fill to max + 5 + for i in range(_CLIENT_CACHE_MAX_SIZE + 5): + mock_client = MagicMock() + mock_client._client = MagicMock() + mock_client._client.is_closed = False + key = (f"evict_test_{i}", False, "", "", "", ()) + with _client_cache_lock: + # Inline the eviction logic (same as _get_cached_client) + while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: + evict_key = next(iter(_client_cache)) + del _client_cache[evict_key] + _client_cache[key] = (mock_client, f"model-{i}", None) + + with _client_cache_lock: + assert len(_client_cache) <= _CLIENT_CACHE_MAX_SIZE, \ + f"Cache size {len(_client_cache)} exceeds max {_CLIENT_CACHE_MAX_SIZE}" + # The earliest entries should have been evicted + assert ("evict_test_0", False, "", "", "", ()) not in _client_cache + # The latest entries should be present + assert (f"evict_test_{_CLIENT_CACHE_MAX_SIZE + 4}", False, "", "", "", ()) in _client_cache + finally: + with _client_cache_lock: + _client_cache.clear() + _client_cache.update(saved) From 0d25e1c146092b36f26205f65e93c25e5147a646 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:28:09 -0700 Subject: [PATCH 195/849] fix: prevent premature loop exit when weak models return empty after substantive tool calls (#10472) The _last_content_with_tools fallback was firing indiscriminately for ALL content+tool turns, including mid-task narration alongside substantive tools (terminal, search_files, etc.). This caused the agent to exit the loop with 'I'll scan the directory...' as the final answer instead of nudging the model to continue processing tool results. The fix restricts the fallback to housekeeping-only turns (memory, todo, skill_manage, session_search) where the content genuinely IS the final answer. When substantive tools are present, the existing post-tool nudge mechanism now fires instead, prompting the model to continue. Affected models: xiaomi/mimo-v2-pro, GLM-5, and other weaker models that intermittently return empty after tool results. Reported by user Renaissance on Discord. --- run_agent.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/run_agent.py b/run_agent.py index d7d1249be..1676d2f5a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7881,6 +7881,7 @@ class AIAgent: self._thinking_prefill_retries = 0 self._post_tool_empty_retried = False self._last_content_with_tools = None + self._last_content_tools_all_housekeeping = False self._mute_post_response = False self._unicode_sanitization_passes = 0 @@ -8068,6 +8069,7 @@ class AIAgent: self._empty_content_retries = 0 self._thinking_prefill_retries = 0 self._last_content_with_tools = None + self._last_content_tools_all_housekeeping = False self._mute_post_response = False # Re-estimate after compression _preflight_tokens = estimate_request_tokens_rough( @@ -10130,6 +10132,7 @@ class AIAgent: tc.function.name in _HOUSEKEEPING_TOOLS for tc in assistant_message.tool_calls ) + self._last_content_tools_all_housekeeping = _all_housekeeping if _all_housekeeping and self._has_stream_consumers(): self._mute_post_response = True elif self.quiet_mode: @@ -10312,15 +10315,22 @@ class AIAgent: break # If the previous turn already delivered real content alongside - # tool calls (e.g. "You're welcome!" + memory save), the model - # has nothing more to say. Use the earlier content immediately - # instead of wasting API calls on retries that won't help. + # HOUSEKEEPING tool calls (e.g. "You're welcome!" + memory save), + # the model has nothing more to say. Use the earlier content + # immediately instead of wasting API calls on retries. + # NOTE: Only use this shortcut when ALL tools in that turn were + # housekeeping (memory, todo, etc.). When substantive tools + # were called (terminal, search_files, etc.), the content was + # likely mid-task narration ("I'll scan the directory...") and + # the empty follow-up means the model choked — let the + # post-tool nudge below handle that instead of exiting early. fallback = getattr(self, '_last_content_with_tools', None) - if fallback: + if fallback and getattr(self, '_last_content_tools_all_housekeeping', False): _turn_exit_reason = "fallback_prior_turn_content" logger.info("Empty follow-up after tool calls — using prior turn content as final response") self._emit_status("↻ Empty response after tool calls — using earlier content as final answer") self._last_content_with_tools = None + self._last_content_tools_all_housekeeping = False self._empty_content_retries = 0 # Do NOT modify the assistant message content — the # old code injected "Calling the X tools..." which @@ -10331,13 +10341,18 @@ class AIAgent: break # ── Post-tool-call empty response nudge ─────────── - # The model returned empty after executing tool calls - # but there's no prior-turn content to fall back on. + # The model returned empty after executing tool calls. + # This covers two cases: + # (a) No prior-turn content at all — model went silent + # (b) Prior turn had content + SUBSTANTIVE tools (the + # fallback above was skipped because the content + # was mid-task narration, not a final answer) # Instead of giving up, nudge the model to continue by # appending a user-level hint. This is the #9400 case: - # weaker models (GLM-5, etc.) sometimes return empty - # after tool results instead of continuing to the next - # step. One retry with a nudge usually fixes it. + # weaker models (mimo-v2-pro, GLM-5, etc.) sometimes + # return empty after tool results instead of continuing + # to the next step. One retry with a nudge usually + # fixes it. _prior_was_tool = any( m.get("role") == "tool" for m in messages[-5:] # check recent messages @@ -10347,6 +10362,10 @@ class AIAgent: and not getattr(self, "_post_tool_empty_retried", False) ): self._post_tool_empty_retried = True + # Clear stale narration so it doesn't resurface + # on a later empty response after the nudge. + self._last_content_with_tools = None + self._last_content_tools_all_housekeeping = False logger.info( "Empty response after tool calls — nudging model " "to continue processing" From a418ddbd8b9e7d3d158ac2dcb7ca281d1c9f602f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:29:05 -0700 Subject: [PATCH 196/849] fix: add activity heartbeats to prevent false gateway inactivity timeouts (#10501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple gaps in activity tracking could cause the gateway's inactivity timeout to fire while the agent is actively working: 1. Streaming wait loop had no periodic heartbeat — the outer thread only touched activity when the stale-stream detector fired (180-300s), and for local providers (Ollama) the stale timeout was infinity, meaning zero heartbeats. Now touches activity every 30s. 2. Concurrent tool execution never set the activity callback on worker threads (threading.local invisible across threads) and never set _current_tool. Workers now set the callback, and the concurrent wait uses a polling loop with 30s heartbeats. 3. Modal backend's execute() override had its own polling loop without any activity callback. Now matches _wait_for_process cadence (10s). --- run_agent.py | 54 ++++++++++++++++++- tests/tools/test_managed_modal_environment.py | 2 +- tools/environments/modal_utils.py | 20 +++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index 1676d2f5a..b0bfa53da 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5522,9 +5522,27 @@ class AIAgent: t = threading.Thread(target=_call, daemon=True) t.start() + _last_heartbeat = time.time() + _HEARTBEAT_INTERVAL = 30.0 # seconds between gateway activity touches while t.is_alive(): t.join(timeout=0.3) + # Periodic heartbeat: touch the agent's activity tracker so the + # gateway's inactivity monitor knows we're alive while waiting + # for stream chunks. Without this, long thinking pauses (e.g. + # reasoning models) or slow prefill on local providers (Ollama) + # trigger false inactivity timeouts. The _call thread touches + # activity on each chunk, but the gap between API call start + # and first chunk can exceed the gateway timeout — especially + # when the stale-stream timeout is disabled (local providers). + _hb_now = time.time() + if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL: + _last_heartbeat = _hb_now + _waiting_secs = int(_hb_now - last_chunk_time["t"]) + self._touch_activity( + f"waiting for stream response ({_waiting_secs}s, no chunks yet)" + ) + # Detect stale streams: connections kept alive by SSE pings # but delivering no real chunks. Kill the client so the # inner retry loop can start a fresh connection. @@ -7141,8 +7159,22 @@ class AIAgent: # Each slot holds (function_name, function_args, function_result, duration, error_flag) results = [None] * num_tools + # Touch activity before launching workers so the gateway knows + # we're executing tools (not stuck). + self._current_tool = tool_names_str + self._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") + def _run_tool(index, tool_call, function_name, function_args): """Worker function executed in a thread.""" + # Set the activity callback on THIS worker thread so + # _wait_for_process (terminal commands) can fire heartbeats. + # The callback is thread-local; the main thread's callback + # is invisible to worker threads. + try: + from tools.environments.base import set_activity_callback + set_activity_callback(self._touch_activity) + except Exception: + pass start = time.time() try: result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id) @@ -7172,8 +7204,26 @@ class AIAgent: f = executor.submit(_run_tool, i, tc, name, args) futures.append(f) - # Wait for all to complete (exceptions are captured inside _run_tool) - concurrent.futures.wait(futures) + # Wait for all to complete with periodic heartbeats so the + # gateway's inactivity monitor doesn't kill us during long + # concurrent tool batches. + _conc_start = time.time() + while True: + done, not_done = concurrent.futures.wait( + futures, timeout=30.0, + ) + if not not_done: + break + _conc_elapsed = int(time.time() - _conc_start) + _still_running = [ + parsed_calls[futures.index(f)][1] + for f in not_done + if f in futures + ] + self._touch_activity( + f"concurrent tools running ({_conc_elapsed}s, " + f"{len(not_done)} remaining: {', '.join(_still_running[:3])})" + ) finally: if spinner: # Build a summary message for the spinner stop diff --git a/tests/tools/test_managed_modal_environment.py b/tests/tools/test_managed_modal_environment.py index 1d7241e0b..d36418336 100644 --- a/tests/tools/test_managed_modal_environment.py +++ b/tests/tools/test_managed_modal_environment.py @@ -296,7 +296,7 @@ def test_managed_modal_execute_times_out_and_cancels(monkeypatch): modal_common = sys.modules["tools.environments.modal_utils"] calls = [] - monotonic_values = iter([0.0, 12.5]) + monotonic_values = iter([0.0, 0.0, 0.0, 12.5, 12.5]) def fake_request(method, url, headers=None, json=None, timeout=None): calls.append((method, url, json, timeout)) diff --git a/tools/environments/modal_utils.py b/tools/environments/modal_utils.py index 0db819471..161aad261 100644 --- a/tools/environments/modal_utils.py +++ b/tools/environments/modal_utils.py @@ -105,6 +105,10 @@ class BaseModalExecutionEnvironment(BaseEnvironment): if self._client_timeout_grace_seconds is not None: deadline = time.monotonic() + prepared.timeout + self._client_timeout_grace_seconds + _last_activity_touch = time.monotonic() + _modal_exec_start = time.monotonic() + _ACTIVITY_INTERVAL = 10.0 # match _wait_for_process cadence + while True: if is_interrupted(): try: @@ -128,6 +132,22 @@ class BaseModalExecutionEnvironment(BaseEnvironment): pass return self._timeout_result_for_modal(prepared.timeout) + # Periodic activity touch so the gateway knows we're alive + _now = time.monotonic() + if _now - _last_activity_touch >= _ACTIVITY_INTERVAL: + _last_activity_touch = _now + try: + from tools.environments.base import _get_activity_callback + _cb = _get_activity_callback() + except Exception: + _cb = None + if _cb: + try: + _elapsed = int(_now - _modal_exec_start) + _cb(f"modal command running ({_elapsed}s elapsed)") + except Exception: + pass + time.sleep(self._poll_interval_seconds) def _before_execute(self) -> None: From 93f6f66872dc2eecc664c1e37d6231268497f388 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:51:55 -0600 Subject: [PATCH 197/849] fix(interrupt): preserve pre-start terminal interrupts --- run_agent.py | 29 +++++++++++--- tests/run_agent/test_interrupt_propagation.py | 39 +++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/run_agent.py b/run_agent.py index b0bfa53da..4d414587e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -754,6 +754,7 @@ class AIAgent: self._interrupt_requested = False self._interrupt_message = None # Optional message that triggered interrupt self._execution_thread_id: int | None = None # Set at run_conversation() start + self._interrupt_thread_signal_pending = False self._client_lock = threading.RLock() # Subagent delegation state @@ -2949,7 +2950,15 @@ class AIAgent: # Signal all tools to abort any in-flight operations immediately. # Scope the interrupt to this agent's execution thread so other # agents running in the same process (gateway) are not affected. - _set_interrupt(True, self._execution_thread_id) + if self._execution_thread_id is not None: + _set_interrupt(True, self._execution_thread_id) + self._interrupt_thread_signal_pending = False + else: + # The interrupt arrived before run_conversation() finished + # binding the agent to its execution thread. Defer the tool-level + # interrupt signal until startup completes instead of targeting + # the caller thread by mistake. + self._interrupt_thread_signal_pending = True # Propagate interrupt to any running child agents (subagent delegation) with self._active_children_lock: children_copy = list(self._active_children) @@ -2965,7 +2974,9 @@ class AIAgent: """Clear any pending interrupt request and the per-thread tool interrupt signal.""" self._interrupt_requested = False self._interrupt_message = None - _set_interrupt(False, self._execution_thread_id) + self._interrupt_thread_signal_pending = False + if self._execution_thread_id is not None: + _set_interrupt(False, self._execution_thread_id) def _touch_activity(self, desc: str) -> None: """Update the last-activity timestamp and description (thread-safe).""" @@ -8179,11 +8190,19 @@ class AIAgent: # Record the execution thread so interrupt()/clear_interrupt() can # scope the tool-level interrupt signal to THIS agent's thread only. - # Must be set before clear_interrupt() which uses it. + # Must be set before any thread-scoped interrupt syncing. self._execution_thread_id = threading.current_thread().ident - # Clear any stale interrupt state at start - self.clear_interrupt() + # Always clear stale per-thread state from a previous turn. If an + # interrupt arrived before startup finished, preserve it and bind it + # to this execution thread now instead of dropping it on the floor. + _set_interrupt(False, self._execution_thread_id) + if self._interrupt_requested: + _set_interrupt(True, self._execution_thread_id) + self._interrupt_thread_signal_pending = False + else: + self._interrupt_message = None + self._interrupt_thread_signal_pending = False # External memory provider: prefetch once before the tool loop. # Reuse the cached result on every iteration to avoid re-calling diff --git a/tests/run_agent/test_interrupt_propagation.py b/tests/run_agent/test_interrupt_propagation.py index a746efdac..ed1f21bfa 100644 --- a/tests/run_agent/test_interrupt_propagation.py +++ b/tests/run_agent/test_interrupt_propagation.py @@ -28,7 +28,8 @@ class TestInterruptPropagationToChild(unittest.TestCase): agent = AIAgent.__new__(AIAgent) agent._interrupt_requested = False agent._interrupt_message = None - agent._execution_thread_id = None # defaults to current thread in set_interrupt + agent._execution_thread_id = None + agent._interrupt_thread_signal_pending = False agent._active_children = [] agent._active_children_lock = threading.Lock() agent.quiet_mode = True @@ -46,15 +47,17 @@ class TestInterruptPropagationToChild(unittest.TestCase): assert parent._interrupt_requested is True assert child._interrupt_requested is True assert child._interrupt_message == "new user message" - assert is_interrupted() is True + assert is_interrupted() is False + assert parent._interrupt_thread_signal_pending is True def test_child_clear_interrupt_at_start_clears_thread(self): """child.clear_interrupt() at start of run_conversation clears the - per-thread interrupt flag for the current thread. + bound execution thread's interrupt flag. """ child = self._make_bare_agent() child._interrupt_requested = True child._interrupt_message = "msg" + child._execution_thread_id = threading.current_thread().ident # Interrupt for current thread is set set_interrupt(True) @@ -128,6 +131,36 @@ class TestInterruptPropagationToChild(unittest.TestCase): child_thread.join(timeout=1) set_interrupt(False) + def test_prestart_interrupt_binds_to_execution_thread(self): + """An interrupt that arrives before startup should bind to the agent thread.""" + agent = self._make_bare_agent() + barrier = threading.Barrier(2) + result = {} + + agent.interrupt("stop before start") + assert agent._interrupt_requested is True + assert agent._interrupt_thread_signal_pending is True + assert is_interrupted() is False + + def run_thread(): + from tools.interrupt import set_interrupt as _set_interrupt_for_test + + agent._execution_thread_id = threading.current_thread().ident + _set_interrupt_for_test(False, agent._execution_thread_id) + if agent._interrupt_requested: + _set_interrupt_for_test(True, agent._execution_thread_id) + agent._interrupt_thread_signal_pending = False + barrier.wait(timeout=5) + result["thread_interrupted"] = is_interrupted() + + t = threading.Thread(target=run_thread) + t.start() + barrier.wait(timeout=5) + t.join(timeout=2) + + assert result["thread_interrupted"] is True + assert agent._interrupt_thread_signal_pending is False + class TestPerThreadInterruptIsolation(unittest.TestCase): """Verify that interrupting one agent does NOT affect another agent's thread. From af4bf505b3754b01a0388907f259826970431ebc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:32:59 -0700 Subject: [PATCH 198/849] fix: add on_memory_write bridge to sequential tool execution path (#10174) (#10507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on_memory_write bridge that notifies external memory providers (ClawMem, retaindb, supermemory, etc.) of built-in memory writes was only present in the concurrent tool execution path (_invoke_tool). The sequential path (_execute_tool_calls_sequential) — which handles all single tool calls, the common case — was missing it entirely. This meant external memory providers silently missed every single-call memory write, which is the vast majority of memory operations. Fix: add the identical bridge block to the sequential path, right after the memory_tool call returns. Closes #10174 --- run_agent.py | 10 +++++ tests/agent/test_memory_provider.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/run_agent.py b/run_agent.py index 4d414587e..359a92185 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7466,6 +7466,16 @@ class AIAgent: old_text=function_args.get("old_text"), store=self._memory_store, ) + # Bridge: notify external memory provider of built-in memory writes + if self._memory_manager and function_args.get("action") in ("add", "replace"): + try: + self._memory_manager.on_memory_write( + function_args.get("action", ""), + target, + function_args.get("content", ""), + ) + except Exception: + pass tool_duration = time.time() - tool_start_time if self._should_emit_quiet_tool_messages(): self._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}") diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 505f40bd5..eba772a04 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -736,3 +736,61 @@ class TestCommitMemorySessionRouting: mgr.add_provider(bad) mgr.on_session_end([]) # must not raise + + +# --------------------------------------------------------------------------- +# on_memory_write bridge — must fire from both concurrent AND sequential paths +# --------------------------------------------------------------------------- + + +class TestOnMemoryWriteBridge: + """Verify that MemoryManager.on_memory_write is called when built-in + memory writes happen. This is a regression test for #10174 where the + sequential tool execution path (_execute_tool_calls_sequential) was + missing the bridge call, so single memory tool calls never notified + external memory providers. + """ + + def test_on_memory_write_add(self): + """on_memory_write fires for 'add' actions.""" + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + mgr.on_memory_write("add", "memory", "new fact") + assert p.memory_writes == [("add", "memory", "new fact")] + + def test_on_memory_write_replace(self): + """on_memory_write fires for 'replace' actions.""" + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + mgr.on_memory_write("replace", "user", "updated pref") + assert p.memory_writes == [("replace", "user", "updated pref")] + + def test_on_memory_write_remove_not_bridged(self): + """The bridge intentionally skips 'remove' — only add/replace notify.""" + # This tests the contract that run_agent.py checks: + # function_args.get("action") in ("add", "replace") + mgr = MemoryManager() + p = FakeMemoryProvider("ext") + mgr.add_provider(p) + + # Manager itself doesn't filter — run_agent.py does. + # But providers should handle remove gracefully. + mgr.on_memory_write("remove", "memory", "old fact") + assert p.memory_writes == [("remove", "memory", "old fact")] + + def test_on_memory_write_tolerates_provider_failure(self): + """If a provider's on_memory_write raises, others still get notified.""" + mgr = MemoryManager() + bad = FakeMemoryProvider("builtin") + bad.on_memory_write = MagicMock(side_effect=RuntimeError("boom")) + good = FakeMemoryProvider("good") + mgr.add_provider(bad) + mgr.add_provider(good) + + mgr.on_memory_write("add", "user", "test") + # Good provider still received the call despite bad provider crashing + assert good.memory_writes == [("add", "user", "test")] From 2edbf155608ae7ea70b3d8fc90ac01b94d311fbc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:35:40 -0700 Subject: [PATCH 199/849] fix: enforce TTL in MessageDeduplicator + use yaml for gateway --config (#10306, #10216) (#10509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gateway fixes: 1. MessageDeduplicator.is_duplicate() now checks TTL at query time (#10306) Previously, is_duplicate() returned True for any previously seen ID without checking its age — expired entries were only purged when cache size exceeded max_size. On normal workloads that never overflow, message IDs stayed deduplicated forever instead of expiring after the TTL. Fix: check `now - timestamp < ttl` before returning True. Expired entries are removed and treated as new messages. 2. Gateway --config flag now uses yaml.safe_load() (#10216) The --config CLI flag in gateway/run.py main() used json.load() to parse config files. YAML is the only documented config format and every other config loader uses yaml.safe_load(). A YAML config file passed via --config would crash with json.JSONDecodeError. Closes #10306 Closes #10216 --- gateway/platforms/helpers.py | 5 +- gateway/run.py | 4 +- tests/gateway/test_message_deduplicator.py | 89 ++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_message_deduplicator.py diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py index c834dd89c..18d97fcb7 100644 --- a/gateway/platforms/helpers.py +++ b/gateway/platforms/helpers.py @@ -49,7 +49,10 @@ class MessageDeduplicator: return False now = time.time() if msg_id in self._seen: - return True + if now - self._seen[msg_id] < self._ttl: + return True + # Entry has expired — remove it and treat as new + del self._seen[msg_id] self._seen[msg_id] = now if len(self._seen) > self._max_size: cutoff = now - self._ttl diff --git a/gateway/run.py b/gateway/run.py index 327f8ae32..ea45dcdd3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9725,9 +9725,9 @@ def main(): config = None if args.config: - import json + import yaml with open(args.config, encoding="utf-8") as f: - data = json.load(f) + data = yaml.safe_load(f) config = GatewayConfig.from_dict(data) # Run the gateway - exit with code 1 if no platforms connected, diff --git a/tests/gateway/test_message_deduplicator.py b/tests/gateway/test_message_deduplicator.py new file mode 100644 index 000000000..59fe7e394 --- /dev/null +++ b/tests/gateway/test_message_deduplicator.py @@ -0,0 +1,89 @@ +"""Tests for MessageDeduplicator TTL enforcement (#10306). + +Previously, is_duplicate() returned True for any previously seen ID without +checking its age — expired entries were only purged when cache size exceeded +max_size. Normal workloads never overflowed, so messages stayed "duplicate" +forever. + +The fix checks TTL at query time: if the entry's timestamp plus TTL is in +the past, the entry is treated as expired and the message is allowed through. +""" + +import time +from unittest.mock import patch + +from gateway.platforms.helpers import MessageDeduplicator + + +class TestMessageDeduplicatorTTL: + """TTL-based expiration must work regardless of cache size.""" + + def test_duplicate_within_ttl(self): + """Same message within TTL window is duplicate.""" + dedup = MessageDeduplicator(ttl_seconds=60) + assert dedup.is_duplicate("msg-1") is False + assert dedup.is_duplicate("msg-1") is True + + def test_not_duplicate_after_ttl_expires(self): + """Same message AFTER TTL expires should NOT be duplicate.""" + dedup = MessageDeduplicator(ttl_seconds=5) + assert dedup.is_duplicate("msg-1") is False + + # Fast-forward time past TTL + dedup._seen["msg-1"] = time.time() - 10 # 10s ago, TTL is 5s + assert dedup.is_duplicate("msg-1") is False, \ + "Expired entry should not be treated as duplicate" + + def test_expired_entry_gets_refreshed(self): + """After an expired entry is allowed through, it should be re-tracked.""" + dedup = MessageDeduplicator(ttl_seconds=5) + assert dedup.is_duplicate("msg-1") is False + + # Expire the entry + dedup._seen["msg-1"] = time.time() - 10 + + # Should be allowed through (expired) + assert dedup.is_duplicate("msg-1") is False + # Now should be duplicate again (freshly tracked) + assert dedup.is_duplicate("msg-1") is True + + def test_different_messages_not_confused(self): + """Different message IDs are independent.""" + dedup = MessageDeduplicator(ttl_seconds=60) + assert dedup.is_duplicate("msg-1") is False + assert dedup.is_duplicate("msg-2") is False + assert dedup.is_duplicate("msg-1") is True + assert dedup.is_duplicate("msg-2") is True + + def test_empty_id_never_duplicate(self): + """Empty/None message IDs are never treated as duplicate.""" + dedup = MessageDeduplicator(ttl_seconds=60) + assert dedup.is_duplicate("") is False + assert dedup.is_duplicate("") is False + + def test_max_size_eviction_prunes_expired(self): + """Cache pruning on overflow removes expired entries.""" + dedup = MessageDeduplicator(max_size=5, ttl_seconds=60) + # Add 6 entries, with the first 3 expired + now = time.time() + for i in range(3): + dedup._seen[f"old-{i}"] = now - 120 # expired (2 min ago, TTL 60s) + for i in range(3): + dedup.is_duplicate(f"new-{i}") + # Now we have 6 entries. Next insert triggers pruning. + dedup.is_duplicate("trigger") + # The 3 expired entries should be gone, leaving 4 fresh ones + assert len(dedup._seen) == 4 + assert "old-0" not in dedup._seen + assert "new-0" in dedup._seen + + def test_ttl_zero_means_no_dedup(self): + """With TTL=0, all entries expire immediately.""" + dedup = MessageDeduplicator(ttl_seconds=0) + assert dedup.is_duplicate("msg-1") is False + # Entry was just added at time.time(), and TTL is 0, + # so now - seen_time >= 0 = ttl, meaning it's expired + # But time.time() might be the exact same float, so + # the check is `now - ts < ttl` which is `0 < 0` = False + # This means TTL=0 effectively disables dedup + assert dedup.is_duplicate("msg-1") is False From 19142810edfd2d3dbe947692732b868d57b9a18e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:40:27 -0700 Subject: [PATCH 200/849] =?UTF-8?q?fix:=20/debug=20privacy=20=E2=80=94=20a?= =?UTF-8?q?uto-delete=20pastes=20after=201=20hour,=20add=20privacy=20notic?= =?UTF-8?q?es=20(#10510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pastes uploaded by /debug now auto-delete after 1 hour via a detached background process that sends DELETE to paste.rs - CLI: shows privacy notice listing what data will be uploaded - Gateway: only uploads summary report (system info + log tails), NOT full log files containing conversation content - Added 'hermes debug delete ' for immediate manual deletion - 16 new tests covering auto-delete scheduling, paste deletion, privacy notices, and the delete subcommand Addresses user privacy concern where /debug uploaded full conversation logs to a public paste service with no warning or expiry. --- gateway/run.py | 44 +++----- hermes_cli/debug.py | 145 ++++++++++++++++++++++++- hermes_cli/main.py | 9 ++ tests/hermes_cli/test_debug.py | 188 ++++++++++++++++++++++++++++++++- 4 files changed, 355 insertions(+), 31 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index ea45dcdd3..4ccc7131a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6803,11 +6803,17 @@ class GatewayRunner: }) async def _handle_debug_command(self, event: MessageEvent) -> str: - """Handle /debug — upload debug report + logs and return paste URLs.""" + """Handle /debug — upload debug report (summary only) and return paste URLs. + + Gateway uploads ONLY the summary report (system info + log tails), + NOT full log files, to protect conversation privacy. Users who need + full log uploads should use ``hermes debug share`` from the CLI. + """ import asyncio from hermes_cli.debug import ( - _capture_dump, collect_debug_report, _read_full_log, - upload_to_pastebin, + _capture_dump, collect_debug_report, + upload_to_pastebin, _schedule_auto_delete, + _GATEWAY_PRIVACY_NOTICE, ) loop = asyncio.get_running_loop() @@ -6816,43 +6822,25 @@ class GatewayRunner: def _collect_and_upload(): dump_text = _capture_dump() report = collect_debug_report(log_lines=200, dump_text=dump_text) - agent_log = _read_full_log("agent") - gateway_log = _read_full_log("gateway") - - if agent_log: - agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log - if gateway_log: - gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log urls = {} - failures = [] - try: urls["Report"] = upload_to_pastebin(report) except Exception as exc: return f"✗ Failed to upload debug report: {exc}" - if agent_log: - try: - urls["agent.log"] = upload_to_pastebin(agent_log) - except Exception: - failures.append("agent.log") + # Schedule auto-deletion after 1 hour + _schedule_auto_delete(list(urls.values())) - if gateway_log: - try: - urls["gateway.log"] = upload_to_pastebin(gateway_log) - except Exception: - failures.append("gateway.log") - - lines = ["**Debug report uploaded:**", ""] + lines = [_GATEWAY_PRIVACY_NOTICE, "", "**Debug report uploaded:**", ""] label_width = max(len(k) for k in urls) for label, url in urls.items(): lines.append(f"`{label:<{label_width}}` {url}") - if failures: - lines.append(f"\n_(failed to upload: {', '.join(failures)})_") - - lines.append("\nShare these links with the Hermes team for support.") + lines.append("") + lines.append("⏱ Pastes will auto-delete in 1 hour.") + lines.append("For full log uploads, use `hermes debug share` from the CLI.") + lines.append("Share these links with the Hermes team for support.") return "\n".join(lines) return await loop.run_in_executor(None, _collect_and_upload) diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 3607db923..12cdb1ba6 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -27,6 +27,110 @@ _DPASTE_COM_URL = "https://dpaste.com/api/" # paste.rs caps at ~1 MB; we stay under that with headroom. _MAX_LOG_BYTES = 512_000 +# Auto-delete pastes after this many seconds (1 hour). +_AUTO_DELETE_SECONDS = 3600 + + +# --------------------------------------------------------------------------- +# Privacy / delete helpers +# --------------------------------------------------------------------------- + +_PRIVACY_NOTICE = """\ +⚠️ This will upload the following to a public paste service: + • System info (OS, Python version, Hermes version, provider, which API keys + are configured — NOT the actual keys) + • Recent log lines (agent.log, errors.log, gateway.log — may contain + conversation fragments and file paths) + • Full agent.log and gateway.log (up to 512 KB each — likely contains + conversation content, tool outputs, and file paths) + +Pastes auto-delete after 1 hour. +""" + +_GATEWAY_PRIVACY_NOTICE = ( + "⚠️ **Privacy notice:** This uploads system info + recent log tails " + "(may contain conversation fragments) to a public paste service. " + "Full logs are NOT included from the gateway — use `hermes debug share` " + "from the CLI for full log uploads.\n" + "Pastes auto-delete after 1 hour." +) + + +def _extract_paste_id(url: str) -> Optional[str]: + """Extract the paste ID from a paste.rs or dpaste.com URL. + + Returns the ID string, or None if the URL doesn't match a known service. + """ + url = url.strip().rstrip("/") + for prefix in ("https://paste.rs/", "http://paste.rs/"): + if url.startswith(prefix): + return url[len(prefix):] + return None + + +def delete_paste(url: str) -> bool: + """Delete a paste from paste.rs. Returns True on success. + + Only paste.rs supports unauthenticated DELETE. dpaste.com pastes + expire automatically but cannot be deleted via API. + """ + paste_id = _extract_paste_id(url) + if not paste_id: + raise ValueError( + f"Cannot delete: only paste.rs URLs are supported. Got: {url}" + ) + + target = f"{_PASTE_RS_URL}{paste_id}" + req = urllib.request.Request( + target, method="DELETE", + headers={"User-Agent": "hermes-agent/debug-share"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return 200 <= resp.status < 300 + + +def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS): + """Spawn a detached process to delete paste.rs pastes after *delay_seconds*. + + The child process is fully detached (``start_new_session=True``) so it + survives the parent exiting (important for CLI mode). Only paste.rs + URLs are attempted — dpaste.com pastes auto-expire on their own. + """ + import subprocess + + paste_rs_urls = [u for u in urls if _extract_paste_id(u)] + if not paste_rs_urls: + return + + # Build a tiny inline Python script. No imports beyond stdlib. + url_list = ", ".join(f'"{u}"' for u in paste_rs_urls) + script = ( + "import time, urllib.request; " + f"time.sleep({delay_seconds}); " + f"[urllib.request.urlopen(urllib.request.Request(u, method='DELETE', " + f"headers={{'User-Agent': 'hermes-agent/auto-delete'}}), timeout=15) " + f"for u in [{url_list}]]" + ) + + try: + subprocess.Popen( + [sys.executable, "-c", script], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass # Best-effort; manual delete still available. + + +def _delete_hint(url: str) -> str: + """Return a one-liner delete command for the given paste URL.""" + paste_id = _extract_paste_id(url) + if paste_id: + return f"hermes debug delete {url}" + # dpaste.com — no API delete, expires on its own. + return "(auto-expires per dpaste.com policy)" + def _upload_paste_rs(content: str) -> str: """Upload to paste.rs. Returns the paste URL. @@ -250,6 +354,9 @@ def run_debug_share(args): expiry = getattr(args, "expire", 7) local_only = getattr(args, "local", False) + if not local_only: + print(_PRIVACY_NOTICE) + print("Collecting debug report...") # Capture dump once — prepended to every paste for context. @@ -315,22 +422,56 @@ def run_debug_share(args): if failures: print(f"\n (failed to upload: {', '.join(failures)})") + # Schedule auto-deletion after 1 hour + _schedule_auto_delete(list(urls.values())) + print(f"\n⏱ Pastes will auto-delete in 1 hour.") + + # Manual delete fallback + print(f"To delete now: hermes debug delete ") + print(f"\nShare these links with the Hermes team for support.") +def run_debug_delete(args): + """Delete one or more paste URLs uploaded by /debug.""" + urls = getattr(args, "urls", []) + if not urls: + print("Usage: hermes debug delete [ ...]") + print(" Deletes paste.rs pastes uploaded by 'hermes debug share'.") + return + + for url in urls: + try: + ok = delete_paste(url) + if ok: + print(f" ✓ Deleted: {url}") + else: + print(f" ✗ Failed to delete: {url} (unexpected response)") + except ValueError as exc: + print(f" ✗ {exc}") + except Exception as exc: + print(f" ✗ Could not delete {url}: {exc}") + + def run_debug(args): """Route debug subcommands.""" subcmd = getattr(args, "debug_command", None) if subcmd == "share": run_debug_share(args) + elif subcmd == "delete": + run_debug_delete(args) else: # Default: show help - print("Usage: hermes debug share [--lines N] [--expire N] [--local]") + print("Usage: hermes debug ") print() print("Commands:") print(" share Upload debug report to a paste service and print URL") + print(" delete Delete a previously uploaded paste") print() - print("Options:") + print("Options (share):") print(" --lines N Number of log lines to include (default: 200)") print(" --expire N Paste expiry in days (default: 7)") print(" --local Print report locally instead of uploading") + print() + print("Options (delete):") + print(" ... One or more paste URLs to delete") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b45c9abb8..2eb47aa54 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5073,6 +5073,7 @@ Examples: hermes debug share --lines 500 Include more log lines hermes debug share --expire 30 Keep paste for 30 days hermes debug share --local Print report locally (no upload) + hermes debug delete Delete a previously uploaded paste """, ) debug_sub = debug_parser.add_subparsers(dest="debug_command") @@ -5092,6 +5093,14 @@ Examples: "--local", action="store_true", help="Print the report locally instead of uploading", ) + delete_parser = debug_sub.add_parser( + "delete", + help="Delete a paste uploaded by 'hermes debug share'", + ) + delete_parser.add_argument( + "urls", nargs="*", default=[], + help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)", + ) debug_parser.set_defaults(func=cmd_debug) # ========================================================================= diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index f733c8ab6..864a64160 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -428,7 +428,9 @@ class TestRunDebug: run_debug(args) out = capsys.readouterr().out - assert "hermes debug share" in out + assert "hermes debug" in out + assert "share" in out + assert "delete" in out def test_share_subcommand_routes(self, hermes_home): from hermes_cli.debug import run_debug @@ -459,3 +461,187 @@ class TestArgparseIntegration: args = MagicMock() args.debug_command = None cmd_debug(args) + + +# --------------------------------------------------------------------------- +# Delete / auto-delete +# --------------------------------------------------------------------------- + +class TestExtractPasteId: + def test_paste_rs_url(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://paste.rs/abc123") == "abc123" + + def test_paste_rs_trailing_slash(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://paste.rs/abc123/") == "abc123" + + def test_http_variant(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("http://paste.rs/xyz") == "xyz" + + def test_non_paste_rs_returns_none(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://dpaste.com/ABCDEF") is None + + def test_empty_returns_none(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("") is None + + +class TestDeletePaste: + def test_delete_sends_delete_request(self): + from hermes_cli.debug import delete_paste + + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", + return_value=mock_resp) as mock_open: + result = delete_paste("https://paste.rs/abc123") + + assert result is True + req = mock_open.call_args[0][0] + assert req.method == "DELETE" + assert "paste.rs/abc123" in req.full_url + + def test_delete_rejects_non_paste_rs(self): + from hermes_cli.debug import delete_paste + + with pytest.raises(ValueError, match="only paste.rs"): + delete_paste("https://dpaste.com/something") + + +class TestScheduleAutoDelete: + def test_spawns_detached_process(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen") as mock_popen: + _schedule_auto_delete( + ["https://paste.rs/abc", "https://paste.rs/def"], + delay_seconds=10, + ) + + mock_popen.assert_called_once() + call_args = mock_popen.call_args + # Verify detached + assert call_args[1]["start_new_session"] is True + # Verify the script references both URLs + script = call_args[0][0][2] # [python, -c, script] + assert "paste.rs/abc" in script + assert "paste.rs/def" in script + assert "time.sleep(10)" in script + + def test_skips_non_paste_rs_urls(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen") as mock_popen: + _schedule_auto_delete(["https://dpaste.com/something"]) + + mock_popen.assert_not_called() + + def test_handles_popen_failure_gracefully(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen", + side_effect=OSError("no such file")): + # Should not raise + _schedule_auto_delete(["https://paste.rs/abc"]) + + +class TestRunDebugDelete: + def test_deletes_valid_url(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = ["https://paste.rs/abc"] + + with patch("hermes_cli.debug.delete_paste", return_value=True): + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Deleted" in out + assert "paste.rs/abc" in out + + def test_handles_delete_failure(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = ["https://paste.rs/abc"] + + with patch("hermes_cli.debug.delete_paste", + side_effect=Exception("network error")): + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Could not delete" in out + + def test_no_urls_shows_usage(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = [] + + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Usage" in out + + +class TestShareIncludesAutoDelete: + """Verify that run_debug_share schedules auto-deletion and prints TTL.""" + + def test_share_schedules_auto_delete(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test1"), \ + patch("hermes_cli.debug._schedule_auto_delete") as mock_sched: + run_debug_share(args) + + # auto-delete was scheduled with the uploaded URLs + mock_sched.assert_called_once() + urls_arg = mock_sched.call_args[0][0] + assert "https://paste.rs/test1" in urls_arg + + out = capsys.readouterr().out + assert "auto-delete" in out + + def test_share_shows_privacy_notice(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test"), \ + patch("hermes_cli.debug._schedule_auto_delete"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "public paste service" in out + + def test_local_no_privacy_notice(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "public paste service" not in out From 861efe274bbe9e5e8c3929db0da07943fae0d2be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:59:57 -0700 Subject: [PATCH 201/849] fix: add ensure_ascii=False to all MCP json.dumps calls (#10234) (#10512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's json.dumps() defaults to ensure_ascii=True, escaping non-ASCII characters to \uXXXX sequences. For CJK characters this inflates token count 3-4x — a single Chinese character like '中' becomes '\u4e2d' (6 chars vs 3 bytes, ~6 tokens vs ~1 token). Since MCP tool results feed directly into the model's conversation context, this silently multiplied API costs for Chinese, Japanese, and Korean users. Fix: add ensure_ascii=False to all 20 json.dumps calls in mcp_tool.py. Raw UTF-8 is valid JSON per RFC 8259 and all downstream consumers (LLM APIs, display) handle it correctly. Closes #10234 --- tools/mcp_tool.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 50655fa38..5f4505224 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -506,7 +506,7 @@ class SamplingHandler: "type": "function", "function": { "name": tu.name, - "arguments": json.dumps(tu.input) if isinstance(tu.input, dict) else str(tu.input), + "arguments": json.dumps(tu.input, ensure_ascii=False) if isinstance(tu.input, dict) else str(tu.input), }, }) msg_dict: dict = {"role": msg.role, "tool_calls": tc_list} @@ -1274,7 +1274,7 @@ def _interrupted_call_result() -> str: """Standardized JSON error for a user-interrupted MCP tool call.""" return json.dumps({ "error": "MCP call interrupted: user sent a new message" - }) + }, ensure_ascii=False) # --------------------------------------------------------------------------- @@ -1361,7 +1361,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" - }) + }, ensure_ascii=False) async def _call(): result = await server.session.call_tool(tool_name, arguments=args) @@ -1375,7 +1375,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): "error": _sanitize_error( error_text or "MCP tool returned an error" ) - }) + }, ensure_ascii=False) # Collect text from content blocks parts: List[str] = [] @@ -1394,9 +1394,9 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): return json.dumps({ "result": text_result, "structuredContent": structured, - }) - return json.dumps({"result": structured}) - return json.dumps({"result": text_result}) + }, ensure_ascii=False) + return json.dumps({"result": structured}, ensure_ascii=False) + return json.dumps({"result": text_result}, ensure_ascii=False) try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) @@ -1411,7 +1411,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): "error": _sanitize_error( f"MCP call failed: {type(exc).__name__}: {exc}" ) - }) + }, ensure_ascii=False) return _handler @@ -1425,7 +1425,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" - }) + }, ensure_ascii=False) async def _call(): result = await server.session.list_resources() @@ -1441,7 +1441,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): if hasattr(r, "mimeType") and r.mimeType: entry["mimeType"] = r.mimeType resources.append(entry) - return json.dumps({"resources": resources}) + return json.dumps({"resources": resources}, ensure_ascii=False) try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) @@ -1455,7 +1455,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float): "error": _sanitize_error( f"MCP call failed: {type(exc).__name__}: {exc}" ) - }) + }, ensure_ascii=False) return _handler @@ -1471,7 +1471,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" - }) + }, ensure_ascii=False) uri = args.get("uri") if not uri: @@ -1487,7 +1487,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): parts.append(block.text) elif hasattr(block, "blob"): parts.append(f"[binary data, {len(block.blob)} bytes]") - return json.dumps({"result": "\n".join(parts) if parts else ""}) + return json.dumps({"result": "\n".join(parts) if parts else ""}, ensure_ascii=False) try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) @@ -1501,7 +1501,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): "error": _sanitize_error( f"MCP call failed: {type(exc).__name__}: {exc}" ) - }) + }, ensure_ascii=False) return _handler @@ -1515,7 +1515,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" - }) + }, ensure_ascii=False) async def _call(): result = await server.session.list_prompts() @@ -1536,7 +1536,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): for a in p.arguments ] prompts.append(entry) - return json.dumps({"prompts": prompts}) + return json.dumps({"prompts": prompts}, ensure_ascii=False) try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) @@ -1550,7 +1550,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float): "error": _sanitize_error( f"MCP call failed: {type(exc).__name__}: {exc}" ) - }) + }, ensure_ascii=False) return _handler @@ -1566,7 +1566,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): if not server or not server.session: return json.dumps({ "error": f"MCP server '{server_name}' is not connected" - }) + }, ensure_ascii=False) name = args.get("name") if not name: @@ -1593,7 +1593,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): resp = {"messages": messages} if hasattr(result, "description") and result.description: resp["description"] = result.description - return json.dumps(resp) + return json.dumps(resp, ensure_ascii=False) try: return _run_on_mcp_loop(_call(), timeout=tool_timeout) @@ -1607,7 +1607,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): "error": _sanitize_error( f"MCP call failed: {type(exc).__name__}: {exc}" ) - }) + }, ensure_ascii=False) return _handler From 91980e35183017998e30e23a9d9abadc02274d17 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:09:32 -0700 Subject: [PATCH 202/849] fix: deduplicate memory provider tools to prevent 400 on strict providers (#10511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory provider plugins (e.g. Mnemosyne) can register tools via two paths: 1. Plugin system (ctx.register_tool) → tool registry → get_tool_definitions() 2. Memory manager → get_all_tool_schemas() → direct append in AIAgent.__init__ Path 2 blindly appended without checking if path 1 already added the same tool names. This created duplicate function names in the tools array sent to the API. Most providers silently handle duplicates, but Xiaomi MiMo (via Nous Portal) strictly rejects them with a 400 Bad Request. Fix: build a set of existing tool names before memory manager injection and skip any tool whose name is already present. Confirmed via live testing against Nous Portal: - Unique tool names → 200 OK - Duplicate tool names → 400 'Provider returned error' --- run_agent.py | 17 ++++++++++-- tests/agent/test_memory_provider.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 359a92185..244fea6b2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1224,14 +1224,27 @@ class AIAgent: logger.warning("Memory provider plugin init failed: %s", _mpe) self._memory_manager = None - # Inject memory provider tool schemas into the tool surface + # Inject memory provider tool schemas into the tool surface. + # Skip tools whose names already exist (plugins may register the + # same tools via ctx.register_tool(), which lands in self.tools + # through get_tool_definitions()). Duplicate function names cause + # 400 errors on providers that enforce unique names (e.g. Xiaomi + # MiMo via Nous Portal). if self._memory_manager and self.tools is not None: + _existing_tool_names = { + t.get("function", {}).get("name") + for t in self.tools + if isinstance(t, dict) + } for _schema in self._memory_manager.get_all_tool_schemas(): + _tname = _schema.get("name", "") + if _tname and _tname in _existing_tool_names: + continue # already registered via plugin path _wrapped = {"type": "function", "function": _schema} self.tools.append(_wrapped) - _tname = _schema.get("name", "") if _tname: self.valid_tool_names.add(_tname) + _existing_tool_names.add(_tname) # Skills config: nudge interval for skill creation reminders self._skill_nudge_interval = 10 diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index eba772a04..7e07d2f33 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -782,6 +782,49 @@ class TestOnMemoryWriteBridge: mgr.on_memory_write("remove", "memory", "old fact") assert p.memory_writes == [("remove", "memory", "old fact")] + def test_memory_manager_tool_injection_deduplicates(self): + """Memory manager tools already in self.tools (from plugin registry) + must not be appended again. Duplicate function names cause 400 errors + on providers that enforce unique names (e.g. Xiaomi MiMo via Nous Portal). + + Regression test for: duplicate mnemosyne_recall / mnemosyne_remember / + mnemosyne_stats in tools array → 400 from Nous Portal. + """ + mgr = MemoryManager() + p = FakeMemoryProvider("ext", tools=[ + {"name": "ext_recall", "description": "Recall", "parameters": {}}, + {"name": "ext_remember", "description": "Remember", "parameters": {}}, + ]) + mgr.add_provider(p) + + # Simulate self.tools already containing one of the plugin tools + # (as if it was registered via ctx.register_tool → get_tool_definitions) + existing_tools = [ + {"type": "function", "function": {"name": "ext_recall", "description": "Recall (from registry)", "parameters": {}}}, + {"type": "function", "function": {"name": "web_search", "description": "Search", "parameters": {}}}, + ] + + # Apply the same dedup logic from run_agent.py __init__ + _existing_names = { + t.get("function", {}).get("name") + for t in existing_tools + if isinstance(t, dict) + } + for _schema in mgr.get_all_tool_schemas(): + _tname = _schema.get("name", "") + if _tname and _tname in _existing_names: + continue + existing_tools.append({"type": "function", "function": _schema}) + if _tname: + _existing_names.add(_tname) + + # ext_recall should NOT be duplicated; ext_remember should be added + tool_names = [t["function"]["name"] for t in existing_tools] + assert tool_names.count("ext_recall") == 1, f"ext_recall duplicated: {tool_names}" + assert tool_names.count("ext_remember") == 1 + assert tool_names.count("web_search") == 1 + assert len(existing_tools) == 3 # web_search + ext_recall + ext_remember + def test_on_memory_write_tolerates_provider_failure(self): """If a provider's on_memory_write raises, others still get notified.""" mgr = MemoryManager() From 824c33729da36d1dbebb0920a1980ec6d86a9344 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:11:05 -0700 Subject: [PATCH 203/849] fix(session_search): coerce limit to int to prevent TypeError with non-int values (#10522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models (especially open-source like qwen3.5-plus) may send non-int values for the limit parameter — None (JSON null), string, or even a type object. This caused TypeError: '<=' not supported between instances of 'int' and 'type' when the value reached min()/comparison operations. Changes: - Add defensive int coercion at session_search() entry with fallback to 3 - Clamp limit to [1, 5] range (was only capped at 5, not floored) - Add tests for None, type object, string, negative, and zero limit values Reported by community user ludoSifu via Discord. --- tests/tools/test_session_search.py | 57 ++++++++++++++++++++++++++++++ tools/session_search_tool.py | 10 +++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 852ac7b9e..f5d75bb91 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -290,6 +290,63 @@ class TestSessionSearch: assert result["results"] == [] assert result["sessions_searched"] == 0 + def test_limit_none_coerced_to_default(self): + """Model sends limit=null → should fall back to 3, not TypeError.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [] + + result = json.loads(session_search( + query="test", db=mock_db, limit=None, + )) + assert result["success"] is True + + def test_limit_type_object_coerced_to_default(self): + """Model sends limit as a type object → should fall back to 3, not TypeError.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [] + + result = json.loads(session_search( + query="test", db=mock_db, limit=int, + )) + assert result["success"] is True + + def test_limit_string_coerced(self): + """Model sends limit as string '2' → should coerce to int.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [] + + result = json.loads(session_search( + query="test", db=mock_db, limit="2", + )) + assert result["success"] is True + + def test_limit_clamped_to_range(self): + """Negative or zero limit should be clamped to 1.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [] + + result = json.loads(session_search( + query="test", db=mock_db, limit=-5, + )) + assert result["success"] is True + + result = json.loads(session_search( + query="test", db=mock_db, limit=0, + )) + assert result["success"] is True + def test_current_root_session_excludes_child_lineage(self): """Delegation child hits should be excluded when they resolve to the current root session.""" from unittest.mock import MagicMock diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 9be73a04a..1398bdfff 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -310,7 +310,15 @@ def session_search( if db is None: return tool_error("Session database not available.", success=False) - limit = min(limit, 5) # Cap at 5 sessions to avoid excessive LLM calls + # Defensive: models (especially open-source) may send non-int limit values + # (None when JSON null, string "int", or even a type object). Coerce to a + # safe integer before any arithmetic/comparison to prevent TypeError. + if not isinstance(limit, int): + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 3 + limit = max(1, min(limit, 5)) # Clamp to [1, 5] # Recent sessions mode: when query is empty, return metadata for recent sessions. # No LLM calls — just DB queries for titles, previews, timestamps. From 305a702e09db54ee850038ea5037cce4ed510e3a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:11:18 -0700 Subject: [PATCH 204/849] fix: /browser connect CDP override now takes priority over Camofox (#10523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user runs /browser connect to attach browser tools to their real Chrome instance via CDP, the BROWSER_CDP_URL env var is set. However, every browser tool function checks _is_camofox_mode() first, which short-circuits to the Camofox backend before _get_session_info() ever checks for the CDP override. Fix: is_camofox_mode() now returns False when BROWSER_CDP_URL is set, so the explicit CDP connection takes priority. This is the correct behavior — /browser connect is an intentional user override. Reported by SkyLinx on Discord. --- tests/tools/test_browser_camofox.py | 12 ++++++++++++ tools/browser_camofox.py | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py index af36f7809..81d69967d 100644 --- a/tests/tools/test_browser_camofox.py +++ b/tests/tools/test_browser_camofox.py @@ -37,6 +37,18 @@ class TestCamofoxMode: monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") assert is_camofox_mode() is True + def test_cdp_override_takes_priority(self, monkeypatch): + """When BROWSER_CDP_URL is set (via /browser connect), CDP takes priority over Camofox.""" + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222") + assert is_camofox_mode() is False + + def test_cdp_override_blank_does_not_disable_camofox(self, monkeypatch): + """Empty/whitespace BROWSER_CDP_URL should not suppress Camofox.""" + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + monkeypatch.setenv("BROWSER_CDP_URL", " ") + assert is_camofox_mode() is True + def test_health_check_unreachable(self, monkeypatch): monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") assert check_camofox_available() is False diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index fbd1c962b..88f486f19 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -54,7 +54,15 @@ def get_camofox_url() -> str: def is_camofox_mode() -> bool: - """True when Camofox backend is configured.""" + """True when Camofox backend is configured and no CDP override is active. + + When the user has explicitly connected to a live Chrome instance via + ``/browser connect`` (which sets ``BROWSER_CDP_URL``), the CDP connection + takes priority over Camofox so the browser tools operate on the real + browser instead of being silently routed to the Camofox backend. + """ + if os.getenv("BROWSER_CDP_URL", "").strip(): + return False return bool(get_camofox_url()) From c4674cbe211006e912b76533163eac8735801ea9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:25:12 -0700 Subject: [PATCH 205/849] fix: parse string schedules in cron update_job() (#10129) (#10521) update_job() assumed the schedule value was always a pre-parsed dict and called .get() on it directly. When the API passes a raw string like "every 10m", this crashed with AttributeError. The create path already handles this correctly by calling parse_schedule() on the incoming string. The fix adds the same normalization to the update path: if the schedule is a string, parse it into a dict before proceeding. Closes #10129 --- cron/jobs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index 47e0b66ef..06d782888 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -501,6 +501,12 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] if schedule_changed: updated_schedule = updated["schedule"] + # The API may pass schedule as a raw string (e.g. "every 10m") + # instead of a pre-parsed dict. Normalize it the same way + # create_job() does so downstream code can call .get() safely. + if isinstance(updated_schedule, str): + updated_schedule = parse_schedule(updated_schedule) + updated["schedule"] = updated_schedule updated["schedule_display"] = updates.get( "schedule_display", updated_schedule.get("display", updated.get("schedule_display")), From 22d22cd75c656bf90f2a179e7df73d06654ed57f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:25:27 -0700 Subject: [PATCH 206/849] fix: auto-register all gateway commands as Discord slash commands (#10528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's _register_slash_commands() had a hardcoded list of ~27 commands while COMMAND_REGISTRY defines 34+ gateway-available commands. Missing commands (debug, branch, rollback, snapshot, profile, yolo, fast, reload, commands) were invisible in Discord's / autocomplete — users couldn't discover them. Add a dynamic catch-all loop after the explicit registrations that iterates COMMAND_REGISTRY, skips already-registered commands, and auto-registers the rest using discord.app_commands.Command(). Commands with args_hint get an optional string parameter; parameterless commands get a simple callback. This ensures any future commands added to COMMAND_REGISTRY automatically appear on Discord without needing a manual entry in discord.py. Telegram and Slack already derive dynamically from COMMAND_REGISTRY via telegram_bot_commands() and slack_subcommand_map() — no changes needed there. --- gateway/platforms/discord.py | 70 ++++++++++++++++++++ tests/gateway/test_discord_slash_commands.py | 51 ++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2d2ea93f9..091b15f61 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1802,6 +1802,76 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_btw(interaction: discord.Interaction, question: str): await self._run_simple_slash(interaction, f"/btw {question}") + # ── Auto-register any gateway-available commands not yet on the tree ── + # This ensures new commands added to COMMAND_REGISTRY in + # hermes_cli/commands.py automatically appear as Discord slash + # commands without needing a manual entry here. + try: + from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates + + already_registered = set() + try: + already_registered = {cmd.name for cmd in tree.get_commands()} + except Exception: + pass + + config_overrides = _resolve_config_gates() + + for cmd_def in COMMAND_REGISTRY: + if not _is_gateway_available(cmd_def, config_overrides): + continue + # Discord command names: lowercase, hyphens OK, max 32 chars. + discord_name = cmd_def.name.lower()[:32] + if discord_name in already_registered: + continue + # Skip aliases that overlap with already-registered names + # (aliases for explicitly registered commands are handled above). + desc = (cmd_def.description or f"Run /{cmd_def.name}")[:100] + has_args = bool(cmd_def.args_hint) + + if has_args: + # Command takes optional arguments — create handler with + # an optional ``args`` string parameter. + def _make_args_handler(_name: str, _hint: str): + @discord.app_commands.describe(args=f"Arguments: {_hint}"[:100]) + async def _handler(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash( + interaction, f"/{_name} {args}".strip() + ) + _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" + return _handler + + handler = _make_args_handler(cmd_def.name, cmd_def.args_hint) + else: + # Parameterless command. + def _make_simple_handler(_name: str): + async def _handler(interaction: discord.Interaction): + await self._run_simple_slash(interaction, f"/{_name}") + _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" + return _handler + + handler = _make_simple_handler(cmd_def.name) + + auto_cmd = discord.app_commands.Command( + name=discord_name, + description=desc, + callback=handler, + ) + try: + tree.add_command(auto_cmd) + already_registered.add(discord_name) + except Exception: + # Silently skip commands that fail registration (e.g. + # name conflict with a subcommand group). + pass + + logger.debug( + "Discord auto-registered %d commands from COMMAND_REGISTRY", + len(already_registered), + ) + except Exception as e: + logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e) + # Register skills under a single /skill command group with category # subcommand groups. This uses 1 top-level slot instead of N, # supporting up to 25 categories × 25 skills = 625 skills. diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index c1c3c1df1..c2f2866eb 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -134,6 +134,57 @@ async def test_registers_native_restart_slash_command(adapter): ) +# ------------------------------------------------------------------ +# Auto-registration from COMMAND_REGISTRY +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_auto_registers_missing_gateway_commands(adapter): + """Commands in COMMAND_REGISTRY that aren't explicitly registered should + be auto-registered by the dynamic catch-all block.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # These commands are gateway-available but were not in the original + # hardcoded registration list — they should be auto-registered. + expected_auto = {"debug", "yolo", "reload", "profile"} + for name in expected_auto: + assert name in tree_names, f"/{name} should be auto-registered on Discord" + + +@pytest.mark.asyncio +async def test_auto_registered_command_dispatches_correctly(adapter): + """Auto-registered commands should dispatch via _run_simple_slash.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + # /debug has no args — test parameterless dispatch + debug_cmd = adapter._client.tree.commands["debug"] + interaction = SimpleNamespace() + adapter._run_simple_slash.reset_mock() + await debug_cmd.callback(interaction) + adapter._run_simple_slash.assert_awaited_once_with(interaction, "/debug") + + +@pytest.mark.asyncio +async def test_auto_registered_command_with_args(adapter): + """Auto-registered commands with args_hint should accept an optional args param.""" + adapter._run_simple_slash = AsyncMock() + adapter._register_slash_commands() + + # /branch has args_hint="[name]" — test dispatch with args + branch_cmd = adapter._client.tree.commands["branch"] + interaction = SimpleNamespace() + adapter._run_simple_slash.reset_mock() + await branch_cmd.callback(interaction, args="my-branch") + adapter._run_simple_slash.assert_awaited_once_with( + interaction, "/branch my-branch" + ) + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ From a9197f9bb18caa9a74162e63bdc18113b444261b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:25:40 -0700 Subject: [PATCH 207/849] fix(memory): discover user-installed memory providers from $HERMES_HOME/plugins/ (#10529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory provider discovery (discover_memory_providers, load_memory_provider) only scanned the bundled plugins/memory/ directory. User-installed providers at $HERMES_HOME/plugins// were invisible, forcing users to symlink into the repo source tree — which broke on hermes update and created a dual-registration path causing duplicate tool names (400 errors on strict providers like Xiaomi MiMo). Changes: - Add _get_user_plugins_dir(), _is_memory_provider_dir(), _iter_provider_dirs(), and find_provider_dir() helpers to plugins/memory/__init__.py - discover_memory_providers() now scans both bundled and user dirs - load_memory_provider() uses find_provider_dir() (bundled-first) - discover_plugin_cli_commands() uses find_provider_dir() - _install_dependencies() in memory_setup.py uses find_provider_dir() - User plugins use _hermes_user_memory namespace to avoid sys.modules collisions - Non-memory user plugins filtered via source text heuristic - Bundled providers always take precedence on name collisions Fixes #4956, #9099. Supersedes #4987, #9123, #9130, #9132, #9982. --- hermes_cli/memory_setup.py | 6 +- plugins/memory/__init__.py | 143 ++++++++++++++++++++++------ tests/agent/test_memory_provider.py | 102 ++++++++++++++++++++ 3 files changed, 222 insertions(+), 29 deletions(-) diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index e6a61316a..88186b8ec 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -58,9 +58,11 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str def _install_dependencies(provider_name: str) -> None: """Install pip dependencies declared in plugin.yaml.""" import subprocess - from pathlib import Path as _Path + from plugins.memory import find_provider_dir - plugin_dir = _Path(__file__).parent.parent / "plugins" / "memory" / provider_name + plugin_dir = find_provider_dir(provider_name) + if not plugin_dir: + return yaml_path = plugin_dir / "plugin.yaml" if not yaml_path.exists(): return diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index cd583e6d8..0ae65a25d 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -1,18 +1,22 @@ """Memory provider plugin discovery. -Scans ``plugins/memory//`` directories for memory provider plugins. -Each subdirectory must contain ``__init__.py`` with a class implementing -the MemoryProvider ABC. +Scans two directories for memory provider plugins: -Memory providers are separate from the general plugin system — they live -in the repo and are always available without user installation. Only ONE -can be active at a time, selected via ``memory.provider`` in config.yaml. +1. Bundled providers: ``plugins/memory//`` (shipped with hermes-agent) +2. User-installed providers: ``$HERMES_HOME/plugins//`` + +Each subdirectory must contain ``__init__.py`` with a class implementing +the MemoryProvider ABC. On name collisions, bundled providers take +precedence. + +Only ONE provider can be active at a time, selected via +``memory.provider`` in config.yaml. Usage: from plugins.memory import discover_memory_providers, load_memory_provider available = discover_memory_providers() # [(name, desc, available), ...] - provider = load_memory_provider("openviking") # MemoryProvider instance + provider = load_memory_provider("mnemosyne") # MemoryProvider instance """ from __future__ import annotations @@ -29,24 +33,101 @@ logger = logging.getLogger(__name__) _MEMORY_PLUGINS_DIR = Path(__file__).parent +# --------------------------------------------------------------------------- +# Directory helpers +# --------------------------------------------------------------------------- + +def _get_user_plugins_dir() -> Optional[Path]: + """Return ``$HERMES_HOME/plugins/`` or None if unavailable.""" + try: + from hermes_constants import get_hermes_home + d = get_hermes_home() / "plugins" + return d if d.is_dir() else None + except Exception: + return None + + +def _is_memory_provider_dir(path: Path) -> bool: + """Heuristic: does *path* look like a memory provider plugin? + + Checks for ``register_memory_provider`` or ``MemoryProvider`` in the + ``__init__.py`` source. Cheap text scan — no import needed. + """ + init_file = path / "__init__.py" + if not init_file.exists(): + return False + try: + source = init_file.read_text(errors="replace")[:8192] + return "register_memory_provider" in source or "MemoryProvider" in source + except Exception: + return False + + +def _iter_provider_dirs() -> List[Tuple[str, Path]]: + """Yield ``(name, path)`` for all discovered provider directories. + + Scans bundled first, then user-installed. Bundled takes precedence + on name collisions (first-seen wins via ``seen`` set). + """ + seen: set = set() + dirs: List[Tuple[str, Path]] = [] + + # 1. Bundled providers (plugins/memory//) + if _MEMORY_PLUGINS_DIR.is_dir(): + for child in sorted(_MEMORY_PLUGINS_DIR.iterdir()): + if not child.is_dir() or child.name.startswith(("_", ".")): + continue + if not (child / "__init__.py").exists(): + continue + seen.add(child.name) + dirs.append((child.name, child)) + + # 2. User-installed providers ($HERMES_HOME/plugins//) + user_dir = _get_user_plugins_dir() + if user_dir: + for child in sorted(user_dir.iterdir()): + if not child.is_dir() or child.name.startswith(("_", ".")): + continue + if child.name in seen: + continue # bundled takes precedence + if not _is_memory_provider_dir(child): + continue # skip non-memory plugins + dirs.append((child.name, child)) + + return dirs + + +def find_provider_dir(name: str) -> Optional[Path]: + """Resolve a provider name to its directory. + + Checks bundled first, then user-installed. + """ + # Bundled + bundled = _MEMORY_PLUGINS_DIR / name + if bundled.is_dir() and (bundled / "__init__.py").exists(): + return bundled + # User-installed + user_dir = _get_user_plugins_dir() + if user_dir: + user = user_dir / name + if user.is_dir() and _is_memory_provider_dir(user): + return user + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + def discover_memory_providers() -> List[Tuple[str, str, bool]]: - """Scan plugins/memory/ for available providers. + """Scan bundled and user-installed directories for available providers. Returns list of (name, description, is_available) tuples. - Does NOT import the providers — just reads plugin.yaml for metadata - and does a lightweight availability check. + Bundled providers take precedence on name collisions. """ results = [] - if not _MEMORY_PLUGINS_DIR.is_dir(): - return results - - for child in sorted(_MEMORY_PLUGINS_DIR.iterdir()): - if not child.is_dir() or child.name.startswith(("_", ".")): - continue - init_file = child / "__init__.py" - if not init_file.exists(): - continue + for name, child in _iter_provider_dirs(): # Read description from plugin.yaml if available desc = "" yaml_file = child / "plugin.yaml" @@ -70,7 +151,7 @@ def discover_memory_providers() -> List[Tuple[str, str, bool]]: except Exception: available = False - results.append((child.name, desc, available)) + results.append((name, desc, available)) return results @@ -78,11 +159,15 @@ def discover_memory_providers() -> List[Tuple[str, str, bool]]: def load_memory_provider(name: str) -> Optional["MemoryProvider"]: """Load and return a MemoryProvider instance by name. + Checks both bundled (``plugins/memory//``) and user-installed + (``$HERMES_HOME/plugins//``) directories. Bundled takes + precedence on name collisions. + Returns None if the provider is not found or fails to load. """ - provider_dir = _MEMORY_PLUGINS_DIR / name - if not provider_dir.is_dir(): - logger.debug("Memory provider '%s' not found in %s", name, _MEMORY_PLUGINS_DIR) + provider_dir = find_provider_dir(name) + if not provider_dir: + logger.debug("Memory provider '%s' not found in bundled or user plugins", name) return None try: @@ -104,7 +189,10 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["MemoryProvider"]: - A top-level class that extends MemoryProvider — we instantiate it """ name = provider_dir.name - module_name = f"plugins.memory.{name}" + # Use a separate namespace for user-installed plugins so they don't + # collide with bundled providers in sys.modules. + _is_bundled = _MEMORY_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _MEMORY_PLUGINS_DIR + module_name = f"plugins.memory.{name}" if _is_bundled else f"_hermes_user_memory.{name}" init_file = provider_dir / "__init__.py" if not init_file.exists(): @@ -257,15 +345,16 @@ def discover_plugin_cli_commands() -> List[dict]: return results # Only look at the active provider's directory - plugin_dir = _MEMORY_PLUGINS_DIR / active_provider - if not plugin_dir.is_dir(): + plugin_dir = find_provider_dir(active_provider) + if not plugin_dir: return results cli_file = plugin_dir / "cli.py" if not cli_file.exists(): return results - module_name = f"plugins.memory.{active_provider}.cli" + _is_bundled = _MEMORY_PLUGINS_DIR in plugin_dir.parents or plugin_dir.parent == _MEMORY_PLUGINS_DIR + module_name = f"plugins.memory.{active_provider}.cli" if _is_bundled else f"_hermes_user_memory.{active_provider}.cli" try: # Import the CLI module (lightweight — no SDK needed) if module_name in sys.modules: diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 7e07d2f33..db2a70c2f 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -396,6 +396,108 @@ class TestPluginMemoryDiscovery: assert load_memory_provider("nonexistent_provider") is None +class TestUserInstalledProviderDiscovery: + """Memory providers installed to $HERMES_HOME/plugins/ should be found. + + Regression test for issues #4956 and #9099: load_memory_provider() and + discover_memory_providers() only scanned the bundled plugins/memory/ + directory, ignoring user-installed plugins. + """ + + def _make_user_memory_plugin(self, tmp_path, name="myprovider"): + """Create a minimal user memory provider plugin.""" + plugin_dir = tmp_path / "plugins" / name + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from agent.memory_provider import MemoryProvider\n" + "class MyProvider(MemoryProvider):\n" + f" @property\n" + f" def name(self): return {name!r}\n" + " def is_available(self): return True\n" + " def initialize(self, **kw): pass\n" + " def sync_turn(self, *a, **kw): pass\n" + " def get_tool_schemas(self): return []\n" + " def handle_tool_call(self, *a, **kw): return '{}'\n" + ) + (plugin_dir / "plugin.yaml").write_text( + f"name: {name}\ndescription: Test user provider\n" + ) + return plugin_dir + + def test_discover_finds_user_plugins(self, tmp_path, monkeypatch): + """discover_memory_providers() includes user-installed plugins.""" + from plugins.memory import discover_memory_providers, _get_user_plugins_dir + self._make_user_memory_plugin(tmp_path, "myexternal") + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + providers = discover_memory_providers() + names = [n for n, _, _ in providers] + assert "myexternal" in names + assert "holographic" in names # bundled still found + + def test_load_user_plugin(self, tmp_path, monkeypatch): + """load_memory_provider() can load from $HERMES_HOME/plugins/.""" + from plugins.memory import load_memory_provider + self._make_user_memory_plugin(tmp_path, "myexternal") + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + p = load_memory_provider("myexternal") + assert p is not None + assert p.name == "myexternal" + assert p.is_available() + + def test_bundled_takes_precedence(self, tmp_path, monkeypatch): + """Bundled provider wins when user plugin has the same name.""" + from plugins.memory import load_memory_provider, discover_memory_providers + # Create user plugin named "holographic" (same as bundled) + plugin_dir = tmp_path / "plugins" / "holographic" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from agent.memory_provider import MemoryProvider\n" + "class Fake(MemoryProvider):\n" + " @property\n" + " def name(self): return 'holographic-FAKE'\n" + " def is_available(self): return True\n" + " def initialize(self, **kw): pass\n" + " def sync_turn(self, *a, **kw): pass\n" + " def get_tool_schemas(self): return []\n" + " def handle_tool_call(self, *a, **kw): return '{}'\n" + ) + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + # Load should return bundled (name "holographic"), not user (name "holographic-FAKE") + p = load_memory_provider("holographic") + assert p is not None + assert p.name == "holographic" # bundled wins + + # discover should not duplicate + providers = discover_memory_providers() + holo_count = sum(1 for n, _, _ in providers if n == "holographic") + assert holo_count == 1 + + def test_non_memory_user_plugins_excluded(self, tmp_path, monkeypatch): + """User plugins that don't reference MemoryProvider are skipped.""" + from plugins.memory import discover_memory_providers + plugin_dir = tmp_path / "plugins" / "notmemory" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "def register(ctx):\n ctx.register_tool('foo', 'bar', {}, lambda: None)\n" + ) + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + providers = discover_memory_providers() + names = [n for n, _, _ in providers] + assert "notmemory" not in names + + # --------------------------------------------------------------------------- # Sequential dispatch routing tests # --------------------------------------------------------------------------- From e36c804bc2a6163117c82d05dbecf5fed67f8f2c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:26:45 -0700 Subject: [PATCH 208/849] fix: prevent already_sent from swallowing empty responses after tool calls (#10531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a model (e.g. mimo-v2-pro) streams intermediate text alongside tool calls ("Let me search for that") but then returns empty after processing tool results, the stream consumer already_sent flag is True from the earlier text delivery. The gateway suppression check (already_sent=True, failed=False → return None) would swallow the final response, leaving the user staring at silence after the search. Two changes: 1. gateway/run.py return path: skip already_sent suppression when the final_response is "(empty)" or empty — the user needs to know the agent finished even if streaming sent partial content earlier. 2. gateway/run.py response handler: convert the internal "(empty)" sentinel to a user-friendly warning instead of delivering the raw sentinel string. Tests added for all empty/None/sentinel cases plus preserved existing suppression behavior for normal non-empty responses. --- gateway/run.py | 24 ++++++- .../test_duplicate_reply_suppression.py | 65 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 4ccc7131a..361678ded 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3874,6 +3874,18 @@ class GatewayRunner: pass response = agent_result.get("final_response") or "" + + # Convert the agent's internal "(empty)" sentinel into a + # user-friendly message. "(empty)" means the model failed to + # produce visible content after exhausting all retries (nudge, + # prefill, empty-retry, fallback). Sending the raw sentinel + # looks like a bug; a short explanation is more helpful. + if response == "(empty)": + response = ( + "⚠️ The model returned no response after processing tool " + "results. This can happen with some models — try again or " + "rephrase your question." + ) agent_messages = agent_result.get("messages", []) _response_time = time.time() - _msg_start_time _api_calls = agent_result.get("api_calls", 0) @@ -9400,9 +9412,19 @@ class GatewayRunner: # BUT: never suppress delivery when the agent failed — the error # message is new content the user hasn't seen, and it must reach # them even if streaming had sent earlier partial output. + # + # Also never suppress when the final response is "(empty)" — this + # means the model failed to produce content after tool calls (common + # with mimo-v2-pro, GLM-5, etc.). The stream consumer may have + # sent intermediate text ("Let me search for that…") alongside the + # tool call, setting already_sent=True, but that text is NOT the + # final answer. Suppressing delivery here leaves the user staring + # at silence. (#10xxx — "agent stops after web search") _sc = stream_consumer_holder[0] if _sc and isinstance(response, dict) and not response.get("failed"): - if ( + _final = response.get("final_response") or "" + _is_empty_sentinel = not _final or _final == "(empty)" + if not _is_empty_sentinel and ( getattr(_sc, "final_response_sent", False) or getattr(_sc, "already_sent", False) ): diff --git a/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py index 5a0ea02f3..d8298db83 100644 --- a/tests/gateway/test_duplicate_reply_suppression.py +++ b/tests/gateway/test_duplicate_reply_suppression.py @@ -232,9 +232,72 @@ class TestAlreadySentWithoutResponsePreviewed: # =================================================================== -# Test 3: run.py queued-message path — _already_streamed detection +# Test 2b: run.py — empty response never suppressed (#10xxx) # =================================================================== +class TestEmptyResponseNotSuppressed: + """When the model returns '(empty)' after tool calls (e.g. mimo-v2-pro + going silent after web_search), the gateway must NOT suppress delivery + even if the stream consumer sent intermediate text earlier. + + Without this fix, the user sees partial streaming text ('Let me search + for that') and then silence — the '(empty)' sentinel is swallowed by + already_sent=True.""" + + def _make_mock_stream_consumer(self, already_sent=False, final_response_sent=False): + return SimpleNamespace( + already_sent=already_sent, + final_response_sent=final_response_sent, + ) + + def _apply_suppression_logic(self, response, sc): + """Reproduce the fixed logic from gateway/run.py return path.""" + if sc and isinstance(response, dict) and not response.get("failed"): + _final = response.get("final_response") or "" + _is_empty_sentinel = not _final or _final == "(empty)" + if not _is_empty_sentinel and ( + getattr(sc, "final_response_sent", False) + or getattr(sc, "already_sent", False) + ): + response["already_sent"] = True + + def test_empty_sentinel_not_suppressed_with_already_sent(self): + """'(empty)' final_response should NOT be suppressed even when + streaming sent intermediate content.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True) + response = {"final_response": "(empty)"} + self._apply_suppression_logic(response, sc) + assert "already_sent" not in response + + def test_empty_string_not_suppressed_with_already_sent(self): + """Empty string final_response should NOT be suppressed.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True) + response = {"final_response": ""} + self._apply_suppression_logic(response, sc) + assert "already_sent" not in response + + def test_none_response_not_suppressed_with_already_sent(self): + """None final_response should NOT be suppressed.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True) + response = {"final_response": None} + self._apply_suppression_logic(response, sc) + assert "already_sent" not in response + + def test_real_response_still_suppressed_with_already_sent(self): + """Normal non-empty response should still be suppressed when + streaming delivered content.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=False) + response = {"final_response": "Here are the search results..."} + self._apply_suppression_logic(response, sc) + assert response.get("already_sent") is True + + def test_failed_empty_response_never_suppressed(self): + """Failed responses are never suppressed regardless of content.""" + sc = self._make_mock_stream_consumer(already_sent=True, final_response_sent=True) + response = {"final_response": "(empty)", "failed": True} + self._apply_suppression_logic(response, sc) + assert "already_sent" not in response + class TestQueuedMessageAlreadyStreamed: """The queued-message path should detect that the first response was already streamed (already_sent=True) even without response_previewed.""" From b3b88a279b970c20d83ad8003a1e96e6a5fb0f76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:27:17 -0700 Subject: [PATCH 209/849] fix: prevent stale os.environ leak after clear_session_vars (#10304) (#10527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After clear_session_vars() reset contextvars to their default (''), get_session_env() treated the empty string as falsy and fell through to os.environ — resurrecting stale HERMES_SESSION_* values from CLI startup, cron, or previous sessions. This broke session isolation in the gateway where concurrent messages could see each other's stale environment values. Fix: use a sentinel (_UNSET) as the contextvar default instead of ''. get_session_env() now checks 'value is not _UNSET' instead of truthiness. Three states are cleanly distinguished: - _UNSET (never set): fall back to os.environ (CLI/cron compat) - '' (explicitly cleared): return '' — no os.environ fallback - 'telegram' (actively set): return the value clear_session_vars() now uses var.set('') instead of var.reset(token) to mark vars as explicitly cleared rather than reverting to _UNSET. Closes #10304 --- gateway/session_context.py | 51 ++++++++++++++++++++----------- tests/gateway/test_session_env.py | 29 +++++++++++++++--- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index b9fdcdfaf..7f8aca3eb 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -37,18 +37,24 @@ needs to replace the import + call site: """ from contextvars import ContextVar +from typing import Any + +# Sentinel to distinguish "never set in this context" from "explicitly set to empty". +# When a contextvar holds _UNSET, we fall back to os.environ (CLI/cron compat). +# When it holds "" (after clear_session_vars resets it), we return "" — no fallback. +_UNSET: Any = object() # --------------------------------------------------------------------------- # Per-task session variables # --------------------------------------------------------------------------- -_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="") -_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="") -_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="") -_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="") -_SESSION_USER_ID: ContextVar[str] = ContextVar("HERMES_SESSION_USER_ID", default="") -_SESSION_USER_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_USER_NAME", default="") -_SESSION_KEY: ContextVar[str] = ContextVar("HERMES_SESSION_KEY", default="") +_SESSION_PLATFORM: ContextVar = ContextVar("HERMES_SESSION_PLATFORM", default=_UNSET) +_SESSION_CHAT_ID: ContextVar = ContextVar("HERMES_SESSION_CHAT_ID", default=_UNSET) +_SESSION_CHAT_NAME: ContextVar = ContextVar("HERMES_SESSION_CHAT_NAME", default=_UNSET) +_SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET) +_SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET) +_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET) +_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET) _VAR_MAP = { "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, @@ -91,10 +97,17 @@ def set_session_vars( def clear_session_vars(tokens: list) -> None: - """Restore session context variables to their pre-handler values.""" - if not tokens: - return - vars_in_order = [ + """Mark session context variables as explicitly cleared. + + Sets all variables to ``""`` so that ``get_session_env`` returns an empty + string instead of falling back to (potentially stale) ``os.environ`` + values. The *tokens* argument is accepted for API compatibility with + callers that saved the return value of ``set_session_vars``, but the + actual clearing uses ``var.set("")`` rather than ``var.reset(token)`` + to ensure the "explicitly cleared" state is distinguishable from + "never set" (which holds the ``_UNSET`` sentinel). + """ + for var in ( _SESSION_PLATFORM, _SESSION_CHAT_ID, _SESSION_CHAT_NAME, @@ -102,9 +115,8 @@ def clear_session_vars(tokens: list) -> None: _SESSION_USER_ID, _SESSION_USER_NAME, _SESSION_KEY, - ] - for var, token in zip(vars_in_order, tokens): - var.reset(token) + ): + var.set("") def get_session_env(name: str, default: str = "") -> str: @@ -113,8 +125,13 @@ def get_session_env(name: str, default: str = "") -> str: Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``. Resolution order: - 1. Context variable (set by the gateway for concurrency-safe access) - 2. ``os.environ`` (used by CLI, cron scheduler, and tests) + 1. Context variable (set by the gateway for concurrency-safe access). + If the variable was explicitly set (even to ``""``) via + ``set_session_vars`` or ``clear_session_vars``, that value is + returned — **no fallback to os.environ**. + 2. ``os.environ`` (only when the context variable was never set in + this context — i.e. CLI, cron scheduler, and test processes that + don't use ``set_session_vars`` at all). 3. *default* """ import os @@ -122,7 +139,7 @@ def get_session_env(name: str, default: str = "") -> str: var = _VAR_MAP.get(name) if var is not None: value = var.get() - if value: + if value is not _UNSET: return value # Fall back to os.environ for CLI, cron, and test compatibility return os.getenv(name, default) diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index 5a643a1ef..85899e2fd 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -1,6 +1,8 @@ import asyncio import os +import pytest + from gateway.config import Platform from gateway.run import GatewayRunner from gateway.session import SessionContext, SessionSource @@ -8,9 +10,26 @@ from gateway.session_context import ( get_session_env, set_session_vars, clear_session_vars, + _VAR_MAP, + _UNSET, ) +@pytest.fixture(autouse=True) +def _reset_contextvars(): + """Reset all session contextvars to _UNSET between tests. + + In production each asyncio.Task gets a fresh context copy where the + defaults are _UNSET. In tests all functions share the same thread + context, so a clear_session_vars() from test A (which sets vars to "") + would leak into test B. This fixture ensures each test starts clean. + """ + yield + for var in _VAR_MAP.values(): + # Can't use var.reset() without a token; just set back to sentinel. + var.set(_UNSET) + + def test_set_session_env_sets_contextvars(monkeypatch): """_set_session_env should populate contextvars, not os.environ.""" runner = object.__new__(GatewayRunner) @@ -98,9 +117,11 @@ def test_get_session_env_falls_back_to_os_environ(monkeypatch): tokens = set_session_vars(platform="telegram") assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" - # Restore — should fall back to os.environ again + # After clear — should return "" (explicitly cleared), NOT fall back + # to os.environ. This is the fix for #10304: stale os.environ values + # must not leak through after a gateway session is cleaned up. clear_session_vars(tokens) - assert get_session_env("HERMES_SESSION_PLATFORM") == "discord" + assert get_session_env("HERMES_SESSION_PLATFORM") == "" def test_get_session_env_default_when_nothing_set(monkeypatch): @@ -164,9 +185,9 @@ def test_session_key_falls_back_to_os_environ(monkeypatch): tokens = set_session_vars(session_key="ctx-session-456") assert get_session_env("HERMES_SESSION_KEY") == "ctx-session-456" - # Restore — should fall back to os.environ + # After clear — should return "" (explicitly cleared), not os.environ (#10304) clear_session_vars(tokens) - assert get_session_env("HERMES_SESSION_KEY") == "env-session-123" + assert get_session_env("HERMES_SESSION_KEY") == "" def test_set_session_env_includes_session_key(): From 407d27bd82ba6d27541d34a0f7b684d3f07f164b Mon Sep 17 00:00:00 2001 From: i3eg1nner Date: Wed, 15 Apr 2026 19:24:07 +0800 Subject: [PATCH 210/849] feat: add SECURITY.md --- SECURITY.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..e757c8897 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,83 @@ +# Hermes Agent Security Policy + +This document outlines the security protocols, trust model, and deployment hardening guidelines for the **Hermes Agent** project. + +## 1. Vulnerability Reporting + +Hermes Agent does **not** operate a bug bounty program. Security issues should be reported via [GitHub Security Advisories (GHSA)](https://github.com/NousResearch/hermes-agent/security/advisories/new) or by emailing **security@nousresearch.com**. Do not open public issues for security vulnerabilities. + +### Required Submission Details +- **Title & Severity:** Concise description and CVSS score/rating. +- **Affected Component:** Exact file path and line range (e.g., `tools/approval.py:120-145`). +- **Environment:** Output of `hermes version`, commit SHA, OS, and Python version. +- **Reproduction:** Step-by-step Proof-of-Concept (PoC) against `main` or the latest release. +- **Impact:** Explanation of what trust boundary was crossed. + +--- + +## 2. Trust Model + +The core assumption is that Hermes is a **personal agent** with one trusted operator. + +### Operator & Session Trust +- **Single Tenant:** The system protects the operator from LLM actions, not from malicious co-tenants. Multi-user isolation must happen at the OS/host level. +- **Gateway Security:** Authorized callers (Telegram, Discord, Slack, etc.) receive equal trust. Session keys are used for routing, not as authorization boundaries. +- **Execution:** Defaults to `terminal.backend: local` (direct host execution). Container isolation (Docker, Modal, Daytona) is opt-in for sandboxing. + +### Dangerous Command Approval +The approval system (`tools/approval.py`) is a core security boundary. Terminal commands, file operations, and other potentially destructive actions are gated behind explicit user confirmation before execution. The approval mode is configurable via `approvals.mode` in `config.yaml`: +- `"on"` (default) — prompts the user to approve dangerous commands. +- `"auto"` — auto-approves after a configurable delay. +- `"off"` — disables the gate entirely (break-glass; see Section 3). + +### Output Redaction +`agent/redact.py` strips secret-like patterns (API keys, tokens, credentials) from all display output before it reaches the terminal or gateway platform. This prevents accidental credential leakage in chat logs, tool previews, and response text. Redaction operates on the display layer only — underlying values remain intact for internal agent operations. + +### Skills vs. MCP Servers +- **Installed Skills:** High trust. Equivalent to local host code; skills can read environment variables and run arbitrary commands. +- **MCP Servers:** Lower trust. MCP subprocesses receive a filtered environment (`_build_safe_env()` in `tools/mcp_tool.py`) — only safe baseline variables (`PATH`, `HOME`, `XDG_*`) plus variables explicitly declared in the server's `env` config block are passed through. Host credentials are stripped by default. Additionally, packages invoked via `npx`/`uvx` are checked against the OSV malware database before spawning. + +### Code Execution Sandbox +The `execute_code` tool (`tools/code_execution_tool.py`) runs LLM-generated Python scripts in a child process with API keys and tokens stripped from the environment to prevent credential exfiltration. Only environment variables explicitly declared by loaded skills (via `env_passthrough`) or by the user in `config.yaml` (`terminal.env_passthrough`) are passed through. The child accesses Hermes tools via RPC, not direct API calls. + +### Subagents +- **No recursive delegation:** The `delegate_task` tool is disabled for child agents. +- **Depth limit:** `MAX_DEPTH = 2` — parent (depth 0) can spawn a child (depth 1); grandchildren are rejected. +- **Memory isolation:** Subagents run with `skip_memory=True` and do not have access to the parent's persistent memory provider. The parent receives only the task prompt and final response as an observation. + +--- + +## 3. Out of Scope (Non-Vulnerabilities) + +The following scenarios are **not** considered security breaches: +- **Prompt Injection:** Unless it results in a concrete bypass of the approval system, toolset restrictions, or container sandbox. +- **Public Exposure:** Deploying the gateway to the public internet without external authentication or network protection. +- **Trusted State Access:** Reports that require pre-existing write access to `~/.hermes/`, `.env`, or `config.yaml` (these are operator-owned files). +- **Default Behavior:** Host-level command execution when `terminal.backend` is set to `local` — this is the documented default, not a vulnerability. +- **Configuration Trade-offs:** Intentional break-glass settings such as `approvals.mode: "off"` or `terminal.backend: local` in production. + +--- + +## 4. Deployment Hardening & Best Practices + +### Filesystem & Network +- **Production sandboxing:** Use container backends (`docker`, `modal`, `daytona`) instead of `local` for untrusted workloads. +- **File permissions:** Run as non-root (the Docker image uses UID 10000); protect credentials with `chmod 600 ~/.hermes/.env` on local installs. +- **Network exposure:** Do not expose the gateway or API server to the public internet without VPN, Tailscale, or firewall protection. SSRF protection is enabled by default across all gateway platform adapters (Telegram, Discord, Slack, Matrix, Mattermost, etc.) with redirect validation. Note: the local terminal backend does not apply SSRF filtering, as it operates within the trusted operator's environment. + +### Skills & Supply Chain +- **Skill installation:** Review Skills Guard reports (`tools/skills_guard.py`) before installing third-party skills. The audit log at `~/.hermes/skills/.hub/audit.log` tracks every install and removal. +- **MCP safety:** OSV malware checking runs automatically for `npx`/`uvx` packages before MCP server processes are spawned. +- **CI/CD:** GitHub Actions are pinned to full commit SHAs. The `supply-chain-audit.yml` workflow blocks PRs containing `.pth` files or suspicious `base64`+`exec` patterns. + +### Credential Storage +- API keys and tokens belong exclusively in `~/.hermes/.env` — never in `config.yaml` or checked into version control. +- The credential pool system (`agent/credential_pool.py`) handles key rotation and fallback. Credentials are resolved from environment variables, not stored in plaintext databases. + +--- + +## 5. Disclosure Process + +- **Coordinated Disclosure:** 90-day window or until a fix is released, whichever comes first. +- **Communication:** All updates occur via the GHSA thread or email correspondence with security@nousresearch.com. +- **Credits:** Reporters are credited in release notes unless anonymity is requested. From 1b12f9b1d6cee2d9c645b05180c617db0140f217 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 14:30:40 -0700 Subject: [PATCH 211/849] docs: add terminal bypass test to Out of Scope section Clarifies that tool-level access restrictions are not security boundaries when the agent has unrestricted terminal access. Deny lists only matter when paired with equivalent terminal-side restrictions (like WRITE_DENIED_PATHS pairs with the dangerous command approval system). --- SECURITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SECURITY.md b/SECURITY.md index e757c8897..3cede2885 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,6 +55,7 @@ The following scenarios are **not** considered security breaches: - **Trusted State Access:** Reports that require pre-existing write access to `~/.hermes/`, `.env`, or `config.yaml` (these are operator-owned files). - **Default Behavior:** Host-level command execution when `terminal.backend` is set to `local` — this is the documented default, not a vulnerability. - **Configuration Trade-offs:** Intentional break-glass settings such as `approvals.mode: "off"` or `terminal.backend: local` in production. +- **Tool-level read/access restrictions:** The agent has unrestricted shell access via the `terminal` tool by design. Reports that a specific tool (e.g., `read_file`) can access a resource are not vulnerabilities if the same access is available through `terminal`. Tool-level deny lists only constitute a meaningful security boundary when paired with equivalent restrictions on the terminal side (as with write operations, where `WRITE_DENIED_PATHS` is paired with the dangerous command approval system). --- From 57e4b61155285cb672f9e179fe668bb0f0f6f703 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 16:34:58 -0500 Subject: [PATCH 212/849] 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 728a8fcce..b6b6dac57 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 14:50:07 -0700 Subject: [PATCH 213/849] fix: handle cross-device shutil.move failure in tirith auto-install (#10127) (#10524) _install_tirith() uses shutil.move() to place the binary from tmpdir to ~/.hermes/bin/. When these are on different filesystems (common in Docker, NFS), shutil.move() falls back to copy2 + unlink, but copy2's metadata step can raise PermissionError. This exception propagated past the fail_open guard, crashing the terminal tool entirely. Additionally, a failed install could leave a non-executable tirith binary at the destination, causing a retry loop on every subsequent terminal command. Fix: - Catch OSError from shutil.move() and fall back to shutil.copy() (skips metadata/xattr copying that causes PermissionError) - If even copy fails, clean up the partial dest file to prevent the non-executable retry loop - Return (None, 'cross_device_copy_failed') so the failure routes through the existing install-failure caching and fail_open logic Closes #10127 --- tools/tirith_security.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b3055944e..44710ee60 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -360,7 +360,21 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: src = os.path.join(tmpdir, "tirith") dest = os.path.join(_hermes_bin_dir(), "tirith") - shutil.move(src, dest) + try: + shutil.move(src, dest) + except OSError: + # Cross-device move (common in Docker, NFS): shutil.move() falls + # back to copy2 + unlink, but copy2's metadata step can raise + # PermissionError. Use plain copy + manual chmod instead. + try: + shutil.copy(src, dest) + except OSError: + # Clean up partial dest to prevent a non-executable retry loop + try: + os.unlink(dest) + except OSError: + pass + return None, "cross_device_copy_failed" os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only" From 096260ce7852910470d6cc142e87174893db17d1 Mon Sep 17 00:00:00 2001 From: Junass1 Date: Wed, 15 Apr 2026 04:09:14 +0300 Subject: [PATCH 214/849] fix(telegram): authorize update prompt callbacks --- gateway/platforms/telegram.py | 22 +++++-- scripts/release.py | 2 +- .../gateway/test_telegram_approval_buttons.py | 62 +++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 112b232d0..0806362b3 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -163,6 +163,15 @@ class TelegramAdapter(BasePlatformAdapter): # Approval button state: message_id → session_key self._approval_state: Dict[int, str] = {} + @staticmethod + def _is_callback_user_authorized(user_id: str) -> bool: + """Return whether a Telegram inline-button caller may perform gated actions.""" + allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + if not allowed_csv: + return True + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + return "*" in allowed_ids or user_id in allowed_ids + def _fallback_ips(self) -> list[str]: """Return validated fallback IPs from config (populated by _apply_env_overrides).""" configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else [] @@ -1440,12 +1449,9 @@ class TelegramAdapter(BasePlatformAdapter): # Only authorized users may click approval buttons. caller_id = str(getattr(query.from_user, "id", "")) - allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() - if allowed_csv: - allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} - if "*" not in allowed_ids and caller_id not in allowed_ids: - await query.answer(text="⛔ You are not authorized to approve commands.") - return + if not self._is_callback_user_authorized(caller_id): + await query.answer(text="⛔ You are not authorized to approve commands.") + return session_key = self._approval_state.pop(approval_id, None) if not session_key: @@ -1490,6 +1496,10 @@ class TelegramAdapter(BasePlatformAdapter): if not data.startswith("update_prompt:"): return answer = data.split(":", 1)[1] # "y" or "n" + caller_id = str(getattr(query.from_user, "id", "")) + if not self._is_callback_user_authorized(caller_id): + await query.answer(text="⛔ You are not authorized to answer update prompts.") + return await query.answer(text=f"Sent '{answer}' to the update process.") # Edit the message to show the choice and remove buttons label = "Yes" if answer == "y" else "No" diff --git a/scripts/release.py b/scripts/release.py index 73d663e55..035fb0969 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -191,7 +191,7 @@ AUTHOR_MAP = { "yangzhi.see@gmail.com": "SeeYangZhi", "yongtenglei@gmail.com": "yongtenglei", "young@YoungdeMacBook-Pro.local": "YoungYang963", - "ysfalweshcan@gmail.com": "Awsh1", + "ysfalweshcan@gmail.com": "Junass1", "ysfwaxlycan@gmail.com": "WAXLYY", "yusufalweshdemir@gmail.com": "Dusk1e", "zhouboli@gmail.com": "zhouboli", diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 98d3cdc31..ec5bbd47e 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -263,7 +263,7 @@ class TestTelegramApprovalCallback: mock_resolve.assert_not_called() @pytest.mark.asyncio - async def test_update_prompt_callback_not_affected(self): + async def test_update_prompt_callback_not_affected(self, tmp_path): """Ensure update prompt callbacks still work.""" adapter = _make_adapter() @@ -281,11 +281,63 @@ class TestTelegramApprovalCallback: context = MagicMock() with patch("tools.approval.resolve_gateway_approval") as mock_resolve: - with patch("hermes_constants.get_hermes_home", return_value=Path("/tmp/test")): - try: + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": ""}): await adapter._handle_callback_query(update, context) - except Exception: - pass # May fail on file write, that's fine # Should NOT have triggered approval resolution mock_resolve.assert_not_called() + assert (tmp_path / ".update_response").read_text() == "y" + + @pytest.mark.asyncio + async def test_update_prompt_callback_rejects_unauthorized_user(self, tmp_path): + """Update prompt buttons should honor TELEGRAM_ALLOWED_USERS.""" + adapter = _make_adapter() + + query = AsyncMock() + query.data = "update_prompt:y" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.from_user.id = 222 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "111"}): + await adapter._handle_callback_query(update, context) + + query.answer.assert_called_once() + assert "not authorized" in query.answer.call_args[1]["text"].lower() + query.edit_message_text.assert_not_called() + assert not (tmp_path / ".update_response").exists() + + @pytest.mark.asyncio + async def test_update_prompt_callback_allows_authorized_user(self, tmp_path): + """Allowed Telegram users can still answer update prompt buttons.""" + adapter = _make_adapter() + + query = AsyncMock() + query.data = "update_prompt:n" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.from_user.id = 111 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "111"}): + await adapter._handle_callback_query(update, context) + + query.answer.assert_called_once() + query.edit_message_text.assert_called_once() + assert (tmp_path / ".update_response").read_text() == "n" From 23f1fa22af4cf94b6d6cb5bafa1326e1b58c9557 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:54:30 -0700 Subject: [PATCH 215/849] fix(kimi): include kimi-coding-cn in Kimi base URL resolution (#10534) Route kimi-coding-cn through _resolve_kimi_base_url() in both get_api_key_provider_status() and resolve_api_key_provider_credentials() so CN users with sk-kimi- prefixed keys get auto-detected to the Kimi Coding Plan endpoint, matching the existing behavior for kimi-coding. Also update the kimi-coding display label to accurately reflect the dual-endpoint setup (Kimi Coding Plan + Moonshot API). Salvaged from PR #10525 by kkikione999. --- hermes_cli/auth.py | 4 ++-- hermes_cli/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 636416a97..1fd9a303c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2384,7 +2384,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id == "kimi-coding": + if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url @@ -2470,7 +2470,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id == "kimi-coding": + if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 18f29c6cd..62c215042 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -526,7 +526,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"), - ProviderEntry("kimi-coding", "Kimi / Moonshot", "Kimi / Moonshot (Moonshot AI direct API)"), + ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"), ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"), ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), From d4eba82a377a72c6b08212c49720c4905e04226f Mon Sep 17 00:00:00 2001 From: LehaoLin Date: Thu, 16 Apr 2026 05:02:34 +0800 Subject: [PATCH 216/849] fix(streaming): don't suppress final response when commentary message is sent Commentary messages (interim assistant status updates like "Using browser tool...") are sent via _send_commentary(), which was incorrectly setting _already_sent = True on success. This caused the final response to be suppressed when there were multiple tool calls, because the gateway checks already_sent to decide whether to skip re-sending the response. The fix: commentary messages are interim status updates, not the final response, so _already_sent should not be set when they succeed. This ensures the final response is always delivered regardless of how many commentary messages were sent during the turn. Fixes: #10454 --- gateway/stream_consumer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index e6d96c802..50321a303 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -609,12 +609,15 @@ class GatewayStreamConsumer: content=text, metadata=self.metadata, ) - if result.success: - self._already_sent = True - return True + # Note: do NOT set _already_sent = True here. + # Commentary messages are interim status updates (e.g. "Using browser + # tool..."), not the final response. Setting already_sent would cause + # the final response to be incorrectly suppressed when there are + # multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454 + return result.success except Exception as e: logger.error("Commentary send error: %s", e) - return False + return False async def _send_or_edit(self, text: str) -> bool: """Send or edit the streaming message. From efd1ddc6e1632871aa7771af8f2df5bed2cd2ed0 Mon Sep 17 00:00:00 2001 From: MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:14:52 +0000 Subject: [PATCH 217/849] fix: sanitize api_messages and extra string fields during ASCII-codec recovery (#6843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ASCII-locale recovery path in run_agent.py sanitized the canonical 'messages' list but left 'api_messages' untouched. api_messages is a separate API-copy built before the retry loop and may carry extra fields (reasoning_content, extra_body entries) that are not present in 'messages'. This caused the retry to still raise UnicodeEncodeError even after the 'System encoding is ASCII — stripped...' log line appeared. Two changes: - _sanitize_messages_non_ascii now walks all extra top-level string fields in each message dict (any key not in {content, name, tool_calls, role}) so reasoning_content and future extras are cleaned in both 'messages' and 'api_messages'. - The ASCII-codec recovery block now also calls sanitize on api_messages and api_kwargs so no non-ASCII survives into the next retry attempt. Adds regression tests covering: - reasoning_content with non-ASCII in api_messages - extra_body with non-ASCII in api_kwargs - canonical messages clean but api_messages dirty Fixes #6843 --- run_agent.py | 21 +++++++ tests/run_agent/test_unicode_ascii_codec.py | 67 ++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index 244fea6b2..a181b11a4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -457,6 +457,15 @@ def _sanitize_messages_non_ascii(messages: list) -> bool: if sanitized != fn_args: fn["arguments"] = sanitized found = True + # Sanitize any additional top-level string fields (e.g. reasoning_content) + for key, value in msg.items(): + if key in {"content", "name", "tool_calls", "role"}: + continue + if isinstance(value, str): + sanitized = _strip_non_ascii(value) + if sanitized != value: + msg[key] = sanitized + found = True return found @@ -9107,7 +9116,19 @@ class AIAgent: # ASCII codec: the system encoding can't handle # non-ASCII characters at all. Sanitize all # non-ASCII content from messages/tool schemas and retry. + # Sanitize both the canonical `messages` list and + # `api_messages` (the API-copy built before the retry + # loop, which may contain extra fields like + # reasoning_content that are not in `messages`). _messages_sanitized = _sanitize_messages_non_ascii(messages) + if isinstance(api_messages, list): + _sanitize_messages_non_ascii(api_messages) + # Also sanitize the last api_kwargs if already built, + # so a leftover non-ASCII value in a transformed field + # (e.g. extra_body, reasoning_content) doesn't survive + # into the next attempt via _build_api_kwargs cache paths. + if isinstance(api_kwargs, dict): + _sanitize_structure_non_ascii(api_kwargs) _prefill_sanitized = False if isinstance(getattr(self, "prefill_messages", None), list): _prefill_sanitized = _sanitize_messages_non_ascii(self.prefill_messages) diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index a8a52c34a..714429b30 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -268,9 +268,9 @@ class TestApiKeyClientSync: agent.client.api_key = _clean_key # All three locations should now hold the clean key - assert agent.api_key == "sk-proj-abcdef" - assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" - assert agent.client.api_key == "sk-proj-abcdef" + assert agent.api_key == "***" + assert agent._client_kwargs["api_key"] == "***" + assert agent.client.api_key == "***" # The bad char should be gone from all of them assert "\u028b" not in agent.api_key assert "\u028b" not in agent._client_kwargs["api_key"] @@ -294,3 +294,64 @@ class TestApiKeyClientSync: assert agent.api_key == "sk-proj-" assert agent.client is None # should not have been touched + + +class TestApiMessagesAndApiKwargsSanitized: + """Regression tests for #6843 follow-up: api_messages and api_kwargs must + be sanitized alongside messages during ASCII-codec recovery. + + The original fix only sanitized the canonical `messages` list. + api_messages is a separate API-copy built before the retry loop; it may + carry extra fields (reasoning_content, extra_body) with non-ASCII chars + that are not present in `messages`. Without sanitizing api_messages and + api_kwargs, the retry still raises UnicodeEncodeError even after the + 'System encoding is ASCII — stripped...' log line appears. + """ + + def test_api_messages_with_reasoning_content_is_sanitized(self): + """api_messages may contain reasoning_content not in messages.""" + api_messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Sure!", + # reasoning_content is injected by the API-copy builder and + # is NOT present in the canonical messages list + "reasoning_content": "Let me think \xab step by step \xbb", + }, + ] + found = _sanitize_messages_non_ascii(api_messages) + assert found is True + assert "\xab" not in api_messages[2]["reasoning_content"] + assert "\xbb" not in api_messages[2]["reasoning_content"] + + def test_api_kwargs_with_non_ascii_extra_body_is_sanitized(self): + """api_kwargs may contain non-ASCII in extra_body or other fields.""" + api_kwargs = { + "model": "glm-5.1", + "messages": [{"role": "user", "content": "ok"}], + "extra_body": { + "system": "Think carefully \u2192 answer", + }, + } + found = _sanitize_structure_non_ascii(api_kwargs) + assert found is True + assert "\u2192" not in api_kwargs["extra_body"]["system"] + + def test_messages_clean_but_api_messages_dirty_both_get_sanitized(self): + """Even when canonical messages are clean, api_messages may be dirty.""" + messages = [{"role": "user", "content": "hello"}] + api_messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "ok", + "reasoning_content": "step \xab done", + }, + ] + # messages sanitize returns False (nothing to clean) + assert _sanitize_messages_non_ascii(messages) is False + # api_messages sanitize must catch the dirty reasoning_content + assert _sanitize_messages_non_ascii(api_messages) is True + assert "\xab" not in api_messages[1]["reasoning_content"] From 902f1e6ede20dd618d64aa6dccced966675f8316 Mon Sep 17 00:00:00 2001 From: MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:19:55 +0000 Subject: [PATCH 218/849] chore: add MestreY0d4-Uninter to AUTHOR_MAP and .mailmap --- .mailmap | 1 + scripts/release.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index 0c385c518..3f093fb5a 100644 --- a/.mailmap +++ b/.mailmap @@ -105,3 +105,4 @@ tesseracttars-creator xinbenlv SaulJWu angelos +MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> diff --git a/scripts/release.py b/scripts/release.py index 035fb0969..b533f94a1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -63,6 +63,7 @@ AUTHOR_MAP = { "70424851+insecurejezza@users.noreply.github.com": "insecurejezza", "259807879+Bartok9@users.noreply.github.com": "Bartok9", "268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1", + "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", # contributors (manual mapping from git names) "dmayhem93@gmail.com": "dmahan93", "samherring99@gmail.com": "samherring99", From 93b6f4522479a7c92ef8dc6a75d71c8c83b7e7f1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 14:56:55 -0700 Subject: [PATCH 219/849] =?UTF-8?q?fix:=20always=20retry=20on=20ASCII=20co?= =?UTF-8?q?dec=20UnicodeEncodeError=20=E2=80=94=20don't=20gate=20on=20per-?= =?UTF-8?q?component=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery block previously only retried (continue) when one of the per-component sanitization checks (messages, tools, system prompt, headers, credentials) found and stripped non-ASCII content. When the non-ASCII lived only in api_messages' reasoning_content field (which is built from messages['reasoning'] and not checked by the original _sanitize_messages_non_ascii), all checks returned False and the recovery fell through to the normal error path — burning a retry attempt despite _force_ascii_payload being set. Now the recovery always continues (retries) when _is_ascii_codec is detected. The _force_ascii_payload flag guarantees the next iteration runs _sanitize_structure_non_ascii(api_kwargs) on the full API payload, catching any remaining non-ASCII regardless of where it lives. Also adds test for the 'reasoning' field on canonical messages. Fixes #6843 --- run_agent.py | 24 +++++++++++++++------ tests/run_agent/test_unicode_ascii_codec.py | 21 +++++++++++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/run_agent.py b/run_agent.py index a181b11a4..b01107814 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9186,22 +9186,34 @@ class AIAgent: force=True, ) - if ( + # Always retry on ASCII codec detection — + # _force_ascii_payload guarantees the full + # api_kwargs payload is sanitized on the + # next iteration (line ~8475). Even when + # per-component checks above find nothing + # (e.g. non-ASCII only in api_messages' + # reasoning_content), the flag catches it. + # Bounded by _unicode_sanitization_passes < 2. + self._unicode_sanitization_passes += 1 + _any_sanitized = ( _messages_sanitized or _prefill_sanitized or _tools_sanitized or _system_sanitized or _headers_sanitized or _credential_sanitized - ): - self._unicode_sanitization_passes += 1 + ) + if _any_sanitized: self._vprint( f"{self.log_prefix}⚠️ System encoding is ASCII — stripped non-ASCII characters from request payload. Retrying...", force=True, ) - continue - # Nothing to sanitize in any payload component. - # Fall through to normal error path. + else: + self._vprint( + f"{self.log_prefix}⚠️ System encoding is ASCII — enabling full-payload sanitization for retry...", + force=True, + ) + continue status_code = getattr(api_error, "status_code", None) error_context = self._extract_api_error_context(api_error) diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index 714429b30..04b5e4043 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -268,9 +268,9 @@ class TestApiKeyClientSync: agent.client.api_key = _clean_key # All three locations should now hold the clean key - assert agent.api_key == "***" - assert agent._client_kwargs["api_key"] == "***" - assert agent.client.api_key == "***" + assert agent.api_key == "sk-proj-abcdef" + assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" + assert agent.client.api_key == "sk-proj-abcdef" # The bad char should be gone from all of them assert "\u028b" not in agent.api_key assert "\u028b" not in agent._client_kwargs["api_key"] @@ -355,3 +355,18 @@ class TestApiMessagesAndApiKwargsSanitized: # api_messages sanitize must catch the dirty reasoning_content assert _sanitize_messages_non_ascii(api_messages) is True assert "\xab" not in api_messages[1]["reasoning_content"] + + def test_reasoning_field_in_canonical_messages_is_sanitized(self): + """The canonical messages list stores reasoning as 'reasoning', not + 'reasoning_content'. The extra-fields loop must catch it.""" + messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "ok", + "reasoning": "Let me think \xab carefully \xbb", + }, + ] + assert _sanitize_messages_non_ascii(messages) is True + assert "\xab" not in messages[1]["reasoning"] + assert "\xbb" not in messages[1]["reasoning"] From 3b4ecf8ee70fcaca38a75f897a8f6c5ef725aafe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:04:01 -0700 Subject: [PATCH 220/849] fix: remove 'q' alias from /quit so /queue's 'q' alias works (#10467) (#10538) Both /queue and /quit registered 'q' as an alias. Since /quit appeared later in COMMAND_REGISTRY, _build_command_lookup() silently overwrote /queue's claim, making the documented /queue shorthand unusable. Fix: remove 'q' from /quit's aliases. /quit already has 'exit' as an alias plus the full '/quit' command. /queue has no other short alias. Closes #10467 --- hermes_cli/commands.py | 2 +- tests/hermes_cli/test_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 516392bd1..c8a0628fa 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -164,7 +164,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Exit CommandDef("quit", "Exit the CLI", "Exit", - cli_only=True, aliases=("exit", "q")), + cli_only=True, aliases=("exit",)), ] diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 5912194b5..8b3597096 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -97,7 +97,7 @@ class TestResolveCommand: def test_alias_resolves_to_canonical(self): assert resolve_command("bg").name == "background" assert resolve_command("reset").name == "new" - assert resolve_command("q").name == "quit" + assert resolve_command("q").name == "queue" assert resolve_command("exit").name == "quit" assert resolve_command("gateway").name == "platforms" assert resolve_command("set-home").name == "sethome" From 96cc556055f5c6ab382197f86d675f59557a3a7e Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:05:03 -0600 Subject: [PATCH 221/849] fix(copilot): preserve base URL and gpt-5-mini routing --- agent/credential_pool.py | 2 + run_agent.py | 46 +++++++++++++++---- tests/agent/test_credential_pool.py | 1 + .../test_run_agent_codex_responses.py | 17 +++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 8a2fecf5d..e1307e51f 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1162,6 +1162,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup if token: source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" active_sources.add(source_name) + pconfig = PROVIDER_REGISTRY.get(provider) changed |= _upsert_entry( entries, provider, @@ -1170,6 +1171,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup "source": source_name, "auth_type": AUTH_TYPE_API_KEY, "access_token": token, + "base_url": pconfig.inference_base_url if pconfig else "", "label": source, }, ) diff --git a/run_agent.py b/run_agent.py index b01107814..956a1e963 100644 --- a/run_agent.py +++ b/run_agent.py @@ -714,12 +714,13 @@ class AIAgent: except Exception: pass - # GPT-5.x models require the Responses API path — they are rejected - # on /v1/chat/completions by both OpenAI and OpenRouter. Also - # auto-upgrade for direct OpenAI URLs (api.openai.com) since all - # newer tool-calling models prefer Responses there. - # ACP runtimes are excluded: CopilotACPClient handles its own - # routing and does not implement the Responses API surface. + # GPT-5.x models usually require the Responses API path, but some + # providers have exceptions (for example Copilot's gpt-5-mini still + # uses chat completions). Also auto-upgrade for direct OpenAI URLs + # (api.openai.com) since all newer tool-calling models prefer + # Responses there. ACP runtimes are excluded: CopilotACPClient + # handles its own routing and does not implement the Responses API + # surface. if ( self.api_mode == "chat_completions" and self.provider != "copilot-acp" @@ -727,7 +728,10 @@ class AIAgent: and not str(self.base_url or "").lower().startswith("acp+tcp://") and ( self._is_direct_openai_url() - or self._model_requires_responses_api(self.model) + or self._provider_model_requires_responses_api( + self.model, + provider=self.provider, + ) ) ): self.api_mode = "codex_responses" @@ -1960,6 +1964,24 @@ class AIAgent: m = m.rsplit("/", 1)[-1] return m.startswith("gpt-5") + @staticmethod + def _provider_model_requires_responses_api( + model: str, + *, + provider: Optional[str] = None, + ) -> bool: + """Return True when this provider/model pair should use Responses API.""" + normalized_provider = (provider or "").strip().lower() + if normalized_provider == "copilot": + try: + from hermes_cli.models import _should_use_copilot_responses_api + return _should_use_copilot_responses_api(model) + except Exception: + # Fall back to the generic GPT-5 rule if Copilot-specific + # logic is unavailable for any reason. + pass + return AIAgent._model_requires_responses_api(model) + def _max_tokens_param(self, value: int) -> dict: """Return the correct max tokens kwarg for the current provider. @@ -5729,9 +5751,13 @@ class AIAgent: fb_api_mode = "anthropic_messages" elif self._is_direct_openai_url(fb_base_url): fb_api_mode = "codex_responses" - elif self._model_requires_responses_api(fb_model): - # GPT-5.x models need Responses API on every provider - # (OpenRouter, Copilot, direct OpenAI, etc.) + elif self._provider_model_requires_responses_api( + fb_model, + provider=fb_provider, + ): + # GPT-5.x models usually need Responses API, but keep + # provider-specific exceptions like Copilot gpt-5-mini on + # chat completions. fb_api_mode = "codex_responses" old_model = self.model diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index ca232c12f..c11782f69 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1091,6 +1091,7 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch): assert len(entries) == 1 assert entries[0].source == "gh_cli" assert entries[0].access_token == "gho_fake_token_abc123" + assert entries[0].base_url == "https://api.githubcopilot.com" def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch): diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 2b2295565..4ff00018d 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -259,6 +259,23 @@ def test_copilot_acp_stays_on_chat_completions_for_gpt_5_models(monkeypatch): assert agent.api_mode == "chat_completions" +def test_copilot_gpt_5_mini_stays_on_chat_completions(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5-mini", + base_url="https://api.githubcopilot.com", + provider="copilot", + api_key="gh-token", + api_mode="chat_completions", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + assert agent.provider == "copilot" + assert agent.api_mode == "chat_completions" + + def test_build_api_kwargs_codex(monkeypatch): agent = _build_agent(monkeypatch) kwargs = agent._build_api_kwargs( From ddaadfb9f0770fa2bff2d73785b4957fbb5619d2 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 14:59:35 -0700 Subject: [PATCH 222/849] chore: add helix4u to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b533f94a1..752cffd9b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -78,6 +78,7 @@ AUTHOR_MAP = { "hakanerten02@hotmail.com": "teyrebaz33", "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", + "4317663+helix4u@users.noreply.github.com": "helix4u", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", From f1df83179f77776bb56dbda30e144e7728beb552 Mon Sep 17 00:00:00 2001 From: Harish Kukreja <331214+counterposition@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:49:59 -0400 Subject: [PATCH 223/849] fix(doctor): skip health check for OpenCode Go (no shared /models endpoint) OpenCode Go does not expose a shared /models endpoint, so the doctor probe was always failing and producing a false warning. Set the default URL to None and disable the health check for this provider. --- hermes_cli/doctor.py | 3 +- tests/hermes_cli/test_doctor.py | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index b89a80409..69a24aff5 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -814,7 +814,8 @@ def run_doctor(args): ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True), ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), - ("OpenCode Go", ("OPENCODE_GO_API_KEY",), "https://opencode.ai/zen/go/v1/models", "OPENCODE_GO_BASE_URL", True), + # OpenCode Go has no shared /models endpoint; skip the health check. + ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False), ] for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers: _key = "" diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index dd15336f6..948cafaf7 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -343,3 +343,57 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, assert "Kimi / Moonshot (China)" in out assert "str expected, not NoneType" not in out assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls) + + +@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"]) +def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test") + if base_url: + monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url) + else: + monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except ImportError: + pass + + calls = [] + + def fake_get(url, headers=None, timeout=None): + calls.append((url, headers, timeout)) + return types.SimpleNamespace(status_code=200) + + import httpx + monkeypatch.setattr(httpx, "get", fake_get) + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert any( + "OpenCode Go" in line and "(key configured)" in line + for line in out.splitlines() + ) + assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls) + assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls) From eb3d928da6a8b3dd5823b159e4d9cff250779d21 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 15:05:11 -0700 Subject: [PATCH 224/849] chore: add counterposition to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 752cffd9b..445750a3c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -79,6 +79,7 @@ AUTHOR_MAP = { "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "4317663+helix4u@users.noreply.github.com": "helix4u", + "331214+counterposition@users.noreply.github.com": "counterposition", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", From de3f8bc6cef8eb0cd0a0b41e6750a110f7ac87f5 Mon Sep 17 00:00:00 2001 From: Ruzzgar Date: Wed, 15 Apr 2026 02:56:31 +0300 Subject: [PATCH 225/849] fix terminal workdir validation for Windows paths --- scripts/release.py | 1 + tests/tools/test_terminal_tool.py | 15 +++++++++++++++ tools/terminal_tool.py | 7 ++++--- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 445750a3c..b40fd7c23 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -76,6 +76,7 @@ AUTHOR_MAP = { "abdullahfarukozden@gmail.com": "Farukest", "lovre.pesut@gmail.com": "rovle", "hakanerten02@hotmail.com": "teyrebaz33", + "ruzzgarcn@gmail.com": "Ruzzgar", "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "4317663+helix4u@users.noreply.github.com": "helix4u", diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index 42ed693a2..dd2a67418 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -88,3 +88,18 @@ def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch): assert transformed == "echo ok && sudo -S -p '' whoami" assert sudo_stdin == "cached-pass\n" + + +def test_validate_workdir_allows_windows_drive_paths(): + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project") is None + assert terminal_tool._validate_workdir("C:/Users/Alice/project") is None + + +def test_validate_workdir_allows_windows_unc_paths(): + assert terminal_tool._validate_workdir(r"\\server\share\project") is None + + +def test_validate_workdir_blocks_shell_metacharacters_in_windows_paths(): + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project; rm -rf /") + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project$(whoami)") + assert terminal_tool._validate_workdir("C:\\Users\\Alice\\project\nwhoami") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 55f4c10a8..1aa266522 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -148,9 +148,10 @@ def _check_all_guards(command: str, env_type: str) -> dict: # Allowlist: characters that can legitimately appear in directory paths. -# Covers alphanumeric, path separators, tilde, dot, hyphen, underscore, space, -# plus, at, equals, and comma. Everything else is rejected. -_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/_\-.~ +@=,]+$') +# Covers alphanumeric, path separators, Windows drive/UNC separators, tilde, +# dot, hyphen, underscore, space, plus, at, equals, and comma. Everything +# else is rejected. +_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/\\:_\-.~ +@=,]+$') def _validate_workdir(workdir: str) -> str | None: From 1d4b9c1a7400d54d2178a327981ca65d25f7cb73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:09:23 -0700 Subject: [PATCH 226/849] fix(gateway): don't treat group session user_id as thread_id in shutdown notifications (#10546) _parse_session_key() blindly assigned parts[5] as thread_id for all chat types. For group sessions with per-user isolation, parts[5] is a user_id, not a thread_id. This could cause shutdown notifications to route with incorrect thread metadata. Only return thread_id for chat types where the 6th element is unambiguous: dm and thread. For group/channel sessions, omit thread_id since the suffix may be a user_id. Based on the approach from PR #9938 by @Ruzzgar. --- gateway/run.py | 9 ++++++-- .../test_background_process_notifications.py | 22 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 361678ded..80797358d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -486,9 +486,14 @@ def _parse_session_key(session_key: str) -> "dict | None": """Parse a session key into its component parts. Session keys follow the format - ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``. + ``agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]``. Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and optionally ``thread_id`` keys, or None if the key doesn't match. + + The 6th element is only returned as ``thread_id`` for chat types where + it is unambiguous (``dm`` and ``thread``). For group/channel sessions + the suffix may be a user_id (per-user isolation) rather than a + thread_id, so we leave ``thread_id`` out to avoid mis-routing. """ parts = session_key.split(":") if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": @@ -497,7 +502,7 @@ def _parse_session_key(session_key: str) -> "dict | None": "chat_type": parts[3], "chat_id": parts[4], } - if len(parts) > 5: + if len(parts) > 5 and parts[3] in ("dm", "thread"): result["thread_id"] = parts[5] return result return None diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index eabf92be6..7351854a2 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -383,15 +383,27 @@ def test_parse_session_key_valid(): def test_parse_session_key_with_extra_parts(): - """Thread ID (6th part) is extracted; further parts are ignored.""" + """6th part in a group key may be a user_id, not a thread_id — omit it.""" result = _parse_session_key("agent:main:discord:group:chan123:thread456") - assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"} + assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"} def test_parse_session_key_with_user_id_part(): - """7th part (user_id) is ignored — only up to thread_id is extracted.""" - result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99") - assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"} + """Group keys with per-user isolation have user_id as 6th part — don't return as thread_id.""" + result = _parse_session_key("agent:main:telegram:group:chat1:user99") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1"} + + +def test_parse_session_key_dm_with_thread(): + """DM keys use parts[5] as thread_id unambiguously.""" + result = _parse_session_key("agent:main:telegram:dm:chat1:topic42") + assert result == {"platform": "telegram", "chat_type": "dm", "chat_id": "chat1", "thread_id": "topic42"} + + +def test_parse_session_key_thread_chat_type(): + """Thread-typed keys use parts[5] as thread_id unambiguously.""" + result = _parse_session_key("agent:main:discord:thread:chan1:thread99") + assert result == {"platform": "discord", "chat_type": "thread", "chat_id": "chan1", "thread_id": "thread99"} def test_parse_session_key_too_short(): From c9f78d110ad2f29b931fb9622fa14509809f6441 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 17:43:38 -0500 Subject: [PATCH 227/849] 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 549314abd..947af602a 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 55dbac86f..eb5a03583 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 19756e8d3..719116cb8 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 fff364689..cad10f648 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 b6b6dac57..3d80e5fb1 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 27bf5e073..0b1d4e95b 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 ee9c0a3ed07d442b72f7330f91d834da201640a4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:08:52 -0700 Subject: [PATCH 228/849] fix(security): add JWT token and Discord mention redaction (#10547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via trace data audit: JWT tokens (eyJ...) and Discord snowflake mentions (<@ID>) were passing through unredacted. JWT pattern: matches 1/2/3-part tokens starting with eyJ (base64 for '{'). Zero false-positive risk — no normal text matches eyJ + 10+ base64url chars. Discord pattern: matches <@digits> and <@!digits> with 17-20 digit snowflake IDs. Syntactically unique to Discord's mention format. Both patterns follow the same structural-uniqueness standard as existing prefix patterns (sk-, ghp_, AKIA, etc.). --- agent/redact.py | 17 +++++++ tests/agent/test_redact.py | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/agent/redact.py b/agent/redact.py index 04d35e3c9..af3b7bb93 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -93,6 +93,17 @@ _DB_CONNSTR_RE = re.compile( re.IGNORECASE, ) +# JWT tokens: header.payload[.signature] — always start with "eyJ" (base64 for "{") +# Matches 1-part (header only), 2-part (header.payload), and full 3-part JWTs. +_JWT_RE = re.compile( + r"eyJ[A-Za-z0-9_-]{10,}" # Header (always starts with eyJ) + r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature +) + +# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678> +# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts. +_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>") + # E.164 phone numbers: +, 7-15 digits # Negative lookahead prevents matching hex strings or identifiers _SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])") @@ -159,6 +170,12 @@ def redact_sensitive_text(text: str) -> str: # Database connection string passwords text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) + # JWT tokens (eyJ... — base64-encoded JSON headers) + text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text) + + # Discord user/role mentions (<@snowflake_id>) + text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text) + # E.164 phone numbers (Signal, WhatsApp) def _redact_phone(m): phone = m.group(1) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 83b1b4d1a..b40e6ef7f 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -284,3 +284,95 @@ class TestElevenLabsTavilyExaKeys: assert "XYZ789abcdef" not in result assert "HOME=/home/user" in result assert "SHELL=/bin/bash" in result + + +class TestJWTTokens: + """JWT tokens start with eyJ (base64 for '{') and have dot-separated parts.""" + + def test_full_3part_jwt(self): + text = ( + "Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJpc3MiOiI0MjNiZDJkYjg4MjI0MDAwIn0" + ".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM" + ) + result = redact_sensitive_text(text) + assert "Token:" in result + # Payload and signature must not survive + assert "eyJpc3Mi" not in result + assert "Gxgv0rru" not in result + + def test_2part_jwt(self): + text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0" + result = redact_sensitive_text(text) + assert "eyJzdWIi" not in result + + def test_standalone_jwt_header(self): + text = "leaked header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 here" + result = redact_sensitive_text(text) + assert "IkpXVCJ9" not in result + assert "leaked header:" in result + + def test_jwt_with_base64_padding(self): + text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=.abc123def456ghij" + result = redact_sensitive_text(text) + assert "abc123def456" not in result + + def test_short_eyj_not_matched(self): + """eyJ followed by fewer than 10 base64 chars should not match.""" + text = "eyJust a normal word" + assert redact_sensitive_text(text) == text + + def test_jwt_preserves_surrounding_text(self): + text = "before eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 after" + result = redact_sensitive_text(text) + assert result.startswith("before ") + assert result.endswith(" after") + + def test_home_assistant_jwt_in_memory(self): + """Real-world pattern: HA token stored in agent memory block.""" + text = ( + "Home Assistant API Token: " + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJpc3MiOiJhYmNkZWYiLCJleHAiOjE3NzQ5NTcxMDN9" + ".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM" + ) + result = redact_sensitive_text(text) + assert "Home Assistant API Token:" in result + assert "Gxgv0rru" not in result + assert "..." in result + + +class TestDiscordMentions: + """Discord snowflake IDs in <@ID> or <@!ID> format.""" + + def test_normal_mention(self): + result = redact_sensitive_text("Hello <@222589316709220353>") + assert "222589316709220353" not in result + assert "<@***>" in result + + def test_nickname_mention(self): + result = redact_sensitive_text("Ping <@!1331549159177846844>") + assert "1331549159177846844" not in result + assert "<@!***>" in result + + def test_multiple_mentions(self): + text = "<@111111111111111111> and <@222222222222222222>" + result = redact_sensitive_text(text) + assert "111111111111111111" not in result + assert "222222222222222222" not in result + + def test_short_id_not_matched(self): + """IDs shorter than 17 digits are not Discord snowflakes.""" + text = "<@12345>" + assert redact_sensitive_text(text) == text + + def test_slack_mention_not_matched(self): + """Slack mentions use letters, not pure digits.""" + text = "<@U024BE7LH>" + assert redact_sensitive_text(text) == text + + def test_preserves_surrounding_text(self): + text = "User <@222589316709220353> said hello" + result = redact_sensitive_text(text) + assert result.startswith("User ") + assert result.endswith(" said hello") From f4724803b42d4394270821572016751b5082fb08 Mon Sep 17 00:00:00 2001 From: MestreY0d4-Uninter Date: Wed, 15 Apr 2026 15:07:11 -0700 Subject: [PATCH 229/849] fix(runtime): surface malformed proxy env and base URL before client init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When proxy env vars (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY) contain malformed URLs — e.g. 'http://127.0.0.1:6153export' from a broken shell config — the OpenAI/httpx client throws a cryptic 'Invalid port' error that doesn't identify the offending variable. Add _validate_proxy_env_urls() and _validate_base_url() in auxiliary_client.py, called from resolve_provider_client() and _create_openai_client() to fail fast with a clear, actionable error message naming the broken env var or URL. Closes #6360 Co-authored-by: MestreY0d4-Uninter --- agent/auxiliary_client.py | 46 +++++++++++++++ run_agent.py | 3 + scripts/release.py | 1 + tests/agent/test_proxy_and_url_validation.py | 60 ++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 tests/agent/test_proxy_and_url_validation.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 479776428..fd2f2d812 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -899,6 +899,51 @@ def _current_custom_base_url() -> str: return custom_base or "" +def _validate_proxy_env_urls() -> None: + """Fail fast with a clear error when proxy env vars have malformed URLs. + + Common cause: shell config (e.g. .zshrc) with a typo like + ``export HTTP_PROXY=http://127.0.0.1:6153export NEXT_VAR=...`` + which concatenates 'export' into the port number. Without this + check the OpenAI/httpx client raises a cryptic ``Invalid port`` + error that doesn't name the offending env var. + """ + from urllib.parse import urlparse + + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy"): + value = str(os.environ.get(key) or "").strip() + if not value: + continue + try: + parsed = urlparse(value) + if parsed.scheme: + _ = parsed.port # raises ValueError for e.g. '6153export' + except ValueError as exc: + raise RuntimeError( + f"Malformed proxy environment variable {key}={value!r}. " + "Fix or unset your proxy settings and try again." + ) from exc + + +def _validate_base_url(base_url: str) -> None: + """Reject obviously broken custom endpoint URLs before they reach httpx.""" + from urllib.parse import urlparse + + candidate = str(base_url or "").strip() + if not candidate or candidate.startswith("acp://"): + return + try: + parsed = urlparse(candidate) + if parsed.scheme in {"http", "https"}: + _ = parsed.port # raises ValueError for malformed ports + except ValueError as exc: + raise RuntimeError( + f"Malformed custom endpoint URL: {candidate!r}. " + "Run `hermes setup` or `hermes model` and enter a valid http(s) base URL." + ) from exc + + def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]: runtime = _resolve_custom_runtime() if len(runtime) == 2: @@ -1299,6 +1344,7 @@ def resolve_provider_client( Returns: (client, resolved_model) or (None, None) if auth is unavailable. """ + _validate_proxy_env_urls() # Normalise aliases provider = _normalize_aux_provider(provider) diff --git a/run_agent.py b/run_agent.py index 956a1e963..3a017f739 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4206,6 +4206,9 @@ class AIAgent: return False def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: bool) -> Any: + from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls + _validate_proxy_env_urls() + _validate_base_url(client_kwargs.get("base_url")) if self.provider == "copilot-acp" or str(client_kwargs.get("base_url", "")).startswith("acp://copilot"): from agent.copilot_acp_client import CopilotACPClient diff --git a/scripts/release.py b/scripts/release.py index b40fd7c23..4e5b19322 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -62,6 +62,7 @@ AUTHOR_MAP = { "258577966+voidborne-d@users.noreply.github.com": "voidborne-d", "70424851+insecurejezza@users.noreply.github.com": "insecurejezza", "259807879+Bartok9@users.noreply.github.com": "Bartok9", + "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", "268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1", "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", # contributors (manual mapping from git names) diff --git a/tests/agent/test_proxy_and_url_validation.py b/tests/agent/test_proxy_and_url_validation.py new file mode 100644 index 000000000..4fd6138a4 --- /dev/null +++ b/tests/agent/test_proxy_and_url_validation.py @@ -0,0 +1,60 @@ +"""Tests for malformed proxy env var and base URL validation. + +Salvaged from PR #6403 by MestreY0d4-Uninter — validates that the agent +surfaces clear errors instead of cryptic httpx ``Invalid port`` exceptions +when proxy env vars or custom endpoint URLs are malformed. +""" +from __future__ import annotations + +import pytest + +from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls + + +# -- proxy env validation ------------------------------------------------ + + +def test_proxy_env_accepts_normal_values(monkeypatch): + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:6153") + monkeypatch.setenv("HTTPS_PROXY", "https://proxy.example.com:8443") + monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.1:1080") + _validate_proxy_env_urls() # should not raise + + +def test_proxy_env_accepts_empty(monkeypatch): + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + _validate_proxy_env_urls() # should not raise + + +@pytest.mark.parametrize("key", [ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "http_proxy", "https_proxy", "all_proxy", +]) +def test_proxy_env_rejects_malformed_port(monkeypatch, key): + monkeypatch.setenv(key, "http://127.0.0.1:6153export") + with pytest.raises(RuntimeError, match=rf"Malformed proxy environment variable {key}=.*6153export"): + _validate_proxy_env_urls() + + +# -- base URL validation ------------------------------------------------- + + +@pytest.mark.parametrize("url", [ + "https://api.example.com/v1", + "http://127.0.0.1:6153/v1", + "acp://copilot", + "", + None, +]) +def test_base_url_accepts_valid(url): + _validate_base_url(url) # should not raise + + +def test_base_url_rejects_malformed_port(): + with pytest.raises(RuntimeError, match="Malformed custom endpoint URL"): + _validate_base_url("http://127.0.0.1:6153export") From 21afc9502aa3c78fb88496d0dc7c68598729b6fa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:10:56 -0700 Subject: [PATCH 230/849] fix: respect explicit api_mode for custom GPT-5 endpoints (#10473) (#10548) The GPT-5 auto-upgrade logic unconditionally overrode api_mode to codex_responses for any model starting with gpt-5, even when the user explicitly set api_mode=chat_completions. Custom proxies that serve GPT-5 via /chat/completions became unusable. Fix: check api_mode is None before the override fires. If the caller passed any explicit api_mode, it is final -- no auto-upgrade. Closes #10473 --- run_agent.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 3a017f739..d229dcfe0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -721,8 +721,11 @@ class AIAgent: # Responses there. ACP runtimes are excluded: CopilotACPClient # handles its own routing and does not implement the Responses API # surface. + # When api_mode was explicitly provided, respect it — the user + # knows what their endpoint supports (#10473). if ( - self.api_mode == "chat_completions" + api_mode is None + and self.api_mode == "chat_completions" and self.provider != "copilot-acp" and not str(self.base_url or "").lower().startswith("acp://copilot") and not str(self.base_url or "").lower().startswith("acp+tcp://") From 0cb8c51fa582e382a5365cbc4bd9a3f7eb2fe280 Mon Sep 17 00:00:00 2001 From: JiaDe WU <40445668+JiaDe-Wu@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:18:01 -0700 Subject: [PATCH 231/849] feat: native AWS Bedrock provider via Converse API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #7920 by JiaDe-Wu — cherry-picked Bedrock-specific additions onto current main, skipping stale-branch reverts (293 commits behind). Dual-path architecture: - Claude models → AnthropicBedrock SDK (prompt caching, thinking budgets) - Non-Claude models → Converse API via boto3 (Nova, DeepSeek, Llama, Mistral) Includes: - Core adapter (agent/bedrock_adapter.py, 1098 lines) - Full provider registration (auth, models, providers, config, runtime, main) - IAM credential chain + Bedrock API Key auth modes - Dynamic model discovery via ListFoundationModels + ListInferenceProfiles - Streaming with delta callbacks, error classification, guardrails - hermes doctor + hermes auth integration - /usage pricing for 7 Bedrock models - 130 automated tests (79 unit + 28 integration + follow-up fixes) - Documentation (website/docs/guides/aws-bedrock.md) - boto3 optional dependency (pip install hermes-agent[bedrock]) Co-authored-by: JiaDe WU <40445668+JiaDe-Wu@users.noreply.github.com> --- agent/anthropic_adapter.py | 27 + agent/bedrock_adapter.py | 1098 ++++++++++++++++++++ agent/error_classifier.py | 9 + agent/model_metadata.py | 10 + agent/usage_pricing.py | 74 ++ hermes_cli/auth.py | 25 + hermes_cli/auth_commands.py | 21 + hermes_cli/config.py | 37 + hermes_cli/doctor.py | 25 + hermes_cli/main.py | 248 +++++ hermes_cli/models.py | 57 ++ hermes_cli/providers.py | 14 + hermes_cli/runtime_provider.py | 73 +- pyproject.toml | 2 + run_agent.py | 178 +++- tests/agent/test_bedrock_adapter.py | 1232 +++++++++++++++++++++++ tests/agent/test_bedrock_integration.py | 269 +++++ website/docs/guides/aws-bedrock.md | 164 +++ 18 files changed, 3543 insertions(+), 20 deletions(-) create mode 100644 agent/bedrock_adapter.py create mode 100644 tests/agent/test_bedrock_adapter.py create mode 100644 tests/agent/test_bedrock_integration.py create mode 100644 website/docs/guides/aws-bedrock.md diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index b85f77a9d..f3f08039d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -298,6 +298,33 @@ def build_anthropic_client(api_key: str, base_url: str = None): return _anthropic_sdk.Anthropic(**kwargs) +def build_anthropic_bedrock_client(region: str): + """Create an AnthropicBedrock client for Bedrock Claude models. + + Uses the Anthropic SDK's native Bedrock adapter, which provides full + Claude feature parity: prompt caching, thinking budgets, adaptive + thinking, fast mode — features not available via the Converse API. + + Auth uses the boto3 default credential chain (IAM roles, SSO, env vars). + """ + if _anthropic_sdk is None: + raise ImportError( + "The 'anthropic' package is required for the Bedrock provider. " + "Install it with: pip install 'anthropic>=0.39.0'" + ) + if not hasattr(_anthropic_sdk, "AnthropicBedrock"): + raise ImportError( + "anthropic.AnthropicBedrock not available. " + "Upgrade with: pip install 'anthropic>=0.39.0'" + ) + from httpx import Timeout + + return _anthropic_sdk.AnthropicBedrock( + aws_region=region, + timeout=Timeout(timeout=900.0, connect=10.0), + ) + + def read_claude_code_credentials() -> Optional[Dict[str, Any]]: """Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json. diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py new file mode 100644 index 000000000..9e4297581 --- /dev/null +++ b/agent/bedrock_adapter.py @@ -0,0 +1,1098 @@ +"""AWS Bedrock Converse API adapter for Hermes Agent. + +Provides native integration with Amazon Bedrock using the Converse API, +bypassing the OpenAI-compatible endpoint in favor of direct AWS SDK calls. +This enables full access to the Bedrock ecosystem: + + - **Native Converse API**: Unified interface for all Bedrock models + (Claude, Nova, Llama, Mistral, etc.) with streaming support. + - **AWS credential chain**: IAM roles, SSO profiles, environment variables, + instance metadata — zero API key management for AWS-native environments. + - **Dynamic model discovery**: Auto-discovers available foundation models + and cross-region inference profiles via the Bedrock control plane. + - **Guardrails support**: Optional Bedrock Guardrails configuration for + content filtering and safety policies. + - **Inference profiles**: Supports cross-region inference profiles + (us.anthropic.claude-*, global.anthropic.claude-*) for better capacity + and automatic failover. + +Architecture follows the same pattern as ``anthropic_adapter.py``: + - All Bedrock-specific logic is isolated in this module. + - Messages/tools are converted between OpenAI format and Converse format. + - Responses are normalized back to OpenAI-compatible objects for the agent loop. + +Reference: OpenClaw's ``extensions/amazon-bedrock/`` plugin, which implements +the same Converse API integration in TypeScript via ``@aws-sdk/client-bedrock``. + +Requires: ``boto3`` (optional dependency — only needed when using the Bedrock provider). +""" + +import json +import logging +import os +import re +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Lazy boto3 import — only loaded when the Bedrock provider is actually used. +# This keeps startup fast for users who don't use Bedrock. +# --------------------------------------------------------------------------- + +_bedrock_runtime_client_cache: Dict[str, Any] = {} +_bedrock_control_client_cache: Dict[str, Any] = {} + + +def _require_boto3(): + """Import boto3, raising a clear error if not installed.""" + try: + import boto3 + return boto3 + except ImportError: + raise ImportError( + "The 'boto3' package is required for the AWS Bedrock provider. " + "Install it with: pip install boto3\n" + "Or install Hermes with Bedrock support: pip install -e '.[bedrock]'" + ) + + +def _get_bedrock_runtime_client(region: str): + """Get or create a cached ``bedrock-runtime`` client for the given region. + + Uses the default AWS credential chain (env vars → profile → instance role). + """ + if region not in _bedrock_runtime_client_cache: + boto3 = _require_boto3() + _bedrock_runtime_client_cache[region] = boto3.client( + "bedrock-runtime", region_name=region, + ) + return _bedrock_runtime_client_cache[region] + + +def _get_bedrock_control_client(region: str): + """Get or create a cached ``bedrock`` control-plane client for model discovery.""" + if region not in _bedrock_control_client_cache: + boto3 = _require_boto3() + _bedrock_control_client_cache[region] = boto3.client( + "bedrock", region_name=region, + ) + return _bedrock_control_client_cache[region] + + +def reset_client_cache(): + """Clear cached boto3 clients. Used in tests and profile switches.""" + _bedrock_runtime_client_cache.clear() + _bedrock_control_client_cache.clear() + + +# --------------------------------------------------------------------------- +# AWS credential detection +# --------------------------------------------------------------------------- + +# Priority order matches OpenClaw's resolveAwsSdkEnvVarName(): +# 1. AWS_BEARER_TOKEN_BEDROCK (Bedrock-specific bearer token) +# 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (explicit IAM credentials) +# 3. AWS_PROFILE (named profile → SSO, assume-role, etc.) +# 4. Implicit: instance role, ECS task role, Lambda execution role +_AWS_CREDENTIAL_ENV_VARS = [ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", + # These are checked by boto3's default chain but we list them for + # has_aws_credentials() detection: + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_WEB_IDENTITY_TOKEN_FILE", +] + + +def resolve_aws_auth_env_var(env: Optional[Dict[str, str]] = None) -> Optional[str]: + """Return the name of the AWS auth source that is active, or None. + + Checks environment variables first, then falls back to boto3's credential + chain for implicit sources (EC2 IMDS, ECS task role, etc.). + + This mirrors OpenClaw's ``resolveAwsSdkEnvVarName()`` — used to detect + whether the user has any AWS credentials configured without actually + attempting to authenticate. + """ + env = env if env is not None else os.environ + # Bearer token takes highest priority + if env.get("AWS_BEARER_TOKEN_BEDROCK", "").strip(): + return "AWS_BEARER_TOKEN_BEDROCK" + # Explicit access key pair + if (env.get("AWS_ACCESS_KEY_ID", "").strip() + and env.get("AWS_SECRET_ACCESS_KEY", "").strip()): + return "AWS_ACCESS_KEY_ID" + # Named profile (SSO, assume-role, etc.) + if env.get("AWS_PROFILE", "").strip(): + return "AWS_PROFILE" + # Container credentials (ECS, CodeBuild) + if env.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "").strip(): + return "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" + # Web identity (EKS IRSA) + if env.get("AWS_WEB_IDENTITY_TOKEN_FILE", "").strip(): + return "AWS_WEB_IDENTITY_TOKEN_FILE" + # No env vars — check if boto3 can resolve credentials via IMDS or other + # implicit sources (EC2 instance role, ECS task role, Lambda, etc.) + try: + import botocore.session + session = botocore.session.get_session() + credentials = session.get_credentials() + if credentials is not None: + resolved = credentials.get_frozen_credentials() + if resolved and resolved.access_key: + return "iam-role" + except Exception: + pass + return None + + +def has_aws_credentials(env: Optional[Dict[str, str]] = None) -> bool: + """Return True if any AWS credential source is detected. + + Checks environment variables first (fast, no I/O), then falls back to + boto3's credential chain which covers EC2 instance roles, ECS task roles, + Lambda execution roles, and other IMDS-based sources that don't set + environment variables. + + This two-tier approach mirrors the pattern from OpenClaw PR #62673: + cloud environments (EC2, ECS, Lambda) provide credentials via instance + metadata, not environment variables. The env-var check is a fast path + for local development; the boto3 fallback covers all cloud deployments. + """ + if resolve_aws_auth_env_var(env) is not None: + return True + # Fall back to boto3's credential resolver — this covers EC2 instance + # metadata (IMDS), ECS container credentials, and other implicit sources + # that don't set environment variables. + try: + import botocore.session + session = botocore.session.get_session() + credentials = session.get_credentials() + if credentials is not None: + resolved = credentials.get_frozen_credentials() + if resolved and resolved.access_key: + return True + except Exception: + pass + return False + + +def resolve_bedrock_region(env: Optional[Dict[str, str]] = None) -> str: + """Resolve the AWS region for Bedrock API calls. + + Priority: AWS_REGION → AWS_DEFAULT_REGION → us-east-1 (fallback). + """ + env = env if env is not None else os.environ + return ( + env.get("AWS_REGION", "").strip() + or env.get("AWS_DEFAULT_REGION", "").strip() + or "us-east-1" + ) + + +# --------------------------------------------------------------------------- +# Tool-calling capability detection +# --------------------------------------------------------------------------- +# Some Bedrock models don't support tool/function calling. Sending toolConfig +# to these models causes ValidationException. We maintain a denylist of known +# non-tool-calling model patterns and strip tools for them. +# +# This is a conservative approach: unknown models are assumed to support tools. +# If a model fails with a tool-related ValidationException, add it here. + +_NON_TOOL_CALLING_PATTERNS = [ + "deepseek.r1", # DeepSeek R1 — reasoning only, no tool support + "deepseek-r1", # Alternate ID format + "stability.", # Image generation models + "cohere.embed", # Embedding models + "amazon.titan-embed", # Embedding models +] + + +def _model_supports_tool_use(model_id: str) -> bool: + """Return True if the model is expected to support tool/function calling. + + Models in the denylist are known to reject toolConfig in the Converse API. + Unknown models default to True (assume tool support). + """ + model_lower = model_id.lower() + return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS) + + +def is_anthropic_bedrock_model(model_id: str) -> bool: + """Return True if the model is an Anthropic Claude model on Bedrock. + + These models should use the AnthropicBedrock SDK path for full feature + parity (prompt caching, thinking budgets, adaptive thinking). + Non-Claude models use the Converse API path. + + Matches: + - ``anthropic.claude-*`` (foundation model IDs) + - ``us.anthropic.claude-*`` (US inference profiles) + - ``global.anthropic.claude-*`` (global inference profiles) + - ``eu.anthropic.claude-*`` (EU inference profiles) + """ + model_lower = model_id.lower() + # Strip regional prefix if present + for prefix in ("us.", "global.", "eu.", "ap.", "jp."): + if model_lower.startswith(prefix): + model_lower = model_lower[len(prefix):] + break + return model_lower.startswith("anthropic.claude") + + +# --------------------------------------------------------------------------- +# Message format conversion: OpenAI → Bedrock Converse +# --------------------------------------------------------------------------- + +def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]: + """Convert OpenAI-format tool definitions to Bedrock Converse ``toolConfig``. + + OpenAI format:: + + {"type": "function", "function": {"name": "...", "description": "...", + "parameters": {"type": "object", "properties": {...}}}} + + Converse format:: + + {"toolSpec": {"name": "...", "description": "...", + "inputSchema": {"json": {"type": "object", "properties": {...}}}}} + """ + if not tools: + return [] + result = [] + for t in tools: + fn = t.get("function", {}) + name = fn.get("name", "") + description = fn.get("description", "") + parameters = fn.get("parameters", {"type": "object", "properties": {}}) + result.append({ + "toolSpec": { + "name": name, + "description": description, + "inputSchema": {"json": parameters}, + } + }) + return result + + +def _convert_content_to_converse(content) -> List[Dict]: + """Convert OpenAI message content (string or list) to Converse content blocks. + + Handles: + - Plain text strings → [{"text": "..."}] + - Content arrays with text/image_url parts → mixed text/image blocks + + Filters out empty text blocks — Bedrock's Converse API rejects messages + where a text content block has an empty ``text`` field (ValidationException: + "text content blocks must be non-empty"). Ref: issue #9486. + """ + if content is None: + return [{"text": " "}] + if isinstance(content, str): + return [{"text": content}] if content.strip() else [{"text": " "}] + if isinstance(content, list): + blocks = [] + for part in content: + if isinstance(part, str): + blocks.append({"text": part}) + continue + if not isinstance(part, dict): + continue + part_type = part.get("type", "") + if part_type == "text": + text = part.get("text", "") + blocks.append({"text": text if text else " "}) + elif part_type == "image_url": + image_url = part.get("image_url", {}) + url = image_url.get("url", "") if isinstance(image_url, dict) else "" + if url.startswith("data:"): + # data:image/jpeg;base64,/9j/4AAQ... + header, _, data = url.partition(",") + media_type = "image/jpeg" + if header.startswith("data:"): + mime_part = header[5:].split(";")[0] + if mime_part: + media_type = mime_part + blocks.append({ + "image": { + "format": media_type.split("/")[-1] if "/" in media_type else "jpeg", + "source": {"bytes": data}, + } + }) + else: + # Remote URL — Converse doesn't support URLs directly, + # include as text reference for the model. + blocks.append({"text": f"[Image: {url}]"}) + return blocks if blocks else [{"text": " "}] + return [{"text": str(content)}] + + +def convert_messages_to_converse( + messages: List[Dict], +) -> Tuple[Optional[List[Dict]], List[Dict]]: + """Convert OpenAI-format messages to Bedrock Converse format. + + Returns ``(system_prompt, converse_messages)`` where: + - ``system_prompt`` is a list of system content blocks (or None) + - ``converse_messages`` is the conversation in Converse format + + Handles: + - System messages → extracted as system prompt + - User messages → ``{"role": "user", "content": [...]}`` + - Assistant messages → ``{"role": "assistant", "content": [...]}`` + - Tool calls → ``{"toolUse": {"toolUseId": ..., "name": ..., "input": ...}}`` + - Tool results → ``{"toolResult": {"toolUseId": ..., "content": [...]}}`` + + Converse requires strict user/assistant alternation. Consecutive messages + with the same role are merged into a single message. + """ + system_blocks: List[Dict] = [] + converse_msgs: List[Dict] = [] + + for msg in messages: + role = msg.get("role", "") + content = msg.get("content") + + if role == "system": + # System messages become the system prompt + if isinstance(content, str) and content.strip(): + system_blocks.append({"text": content}) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + system_blocks.append({"text": part.get("text", "")}) + elif isinstance(part, str): + system_blocks.append({"text": part}) + continue + + if role == "tool": + # Tool result messages → merge into the preceding user turn + tool_call_id = msg.get("tool_call_id", "") + result_content = content if isinstance(content, str) else json.dumps(content) + tool_result_block = { + "toolResult": { + "toolUseId": tool_call_id, + "content": [{"text": result_content}], + } + } + # In Converse, tool results go in a "user" role message + if converse_msgs and converse_msgs[-1]["role"] == "user": + converse_msgs[-1]["content"].append(tool_result_block) + else: + converse_msgs.append({ + "role": "user", + "content": [tool_result_block], + }) + continue + + if role == "assistant": + content_blocks = [] + # Convert text content + if isinstance(content, str) and content.strip(): + content_blocks.append({"text": content}) + elif isinstance(content, list): + content_blocks.extend(_convert_content_to_converse(content)) + + # Convert tool calls + tool_calls = msg.get("tool_calls", []) + for tc in (tool_calls or []): + fn = tc.get("function", {}) + args_str = fn.get("arguments", "{}") + try: + args_dict = json.loads(args_str) if isinstance(args_str, str) else args_str + except (json.JSONDecodeError, TypeError): + args_dict = {} + content_blocks.append({ + "toolUse": { + "toolUseId": tc.get("id", ""), + "name": fn.get("name", ""), + "input": args_dict, + } + }) + + if not content_blocks: + content_blocks = [{"text": " "}] + + # Merge with previous assistant message if needed (strict alternation) + if converse_msgs and converse_msgs[-1]["role"] == "assistant": + converse_msgs[-1]["content"].extend(content_blocks) + else: + converse_msgs.append({ + "role": "assistant", + "content": content_blocks, + }) + continue + + if role == "user": + content_blocks = _convert_content_to_converse(content) + # Merge with previous user message if needed (strict alternation) + if converse_msgs and converse_msgs[-1]["role"] == "user": + converse_msgs[-1]["content"].extend(content_blocks) + else: + converse_msgs.append({ + "role": "user", + "content": content_blocks, + }) + continue + + # Converse requires the first message to be from the user + if converse_msgs and converse_msgs[0]["role"] != "user": + converse_msgs.insert(0, {"role": "user", "content": [{"text": " "}]}) + + # Converse requires the last message to be from the user + if converse_msgs and converse_msgs[-1]["role"] != "user": + converse_msgs.append({"role": "user", "content": [{"text": " "}]}) + + return (system_blocks if system_blocks else None, converse_msgs) + + +# --------------------------------------------------------------------------- +# Response format conversion: Bedrock Converse → OpenAI +# --------------------------------------------------------------------------- + +def _converse_stop_reason_to_openai(stop_reason: str) -> str: + """Map Bedrock Converse stop reasons to OpenAI finish_reason values.""" + mapping = { + "end_turn": "stop", + "stop_sequence": "stop", + "tool_use": "tool_calls", + "max_tokens": "length", + "content_filtered": "content_filter", + "guardrail_intervened": "content_filter", + } + return mapping.get(stop_reason, "stop") + + +def normalize_converse_response(response: Dict) -> SimpleNamespace: + """Convert a Bedrock Converse API response to an OpenAI-compatible object. + + The agent loop in ``run_agent.py`` expects responses shaped like + ``openai.ChatCompletion`` — this function bridges the gap. + + Returns a SimpleNamespace with: + - ``.choices[0].message.content`` — text response + - ``.choices[0].message.tool_calls`` — tool call list (if any) + - ``.choices[0].finish_reason`` — stop/tool_calls/length + - ``.usage`` — token usage stats + """ + output = response.get("output", {}) + message = output.get("message", {}) + content_blocks = message.get("content", []) + stop_reason = response.get("stopReason", "end_turn") + + text_parts = [] + tool_calls = [] + + for block in content_blocks: + if "text" in block: + text_parts.append(block["text"]) + elif "toolUse" in block: + tu = block["toolUse"] + tool_calls.append(SimpleNamespace( + id=tu.get("toolUseId", ""), + type="function", + function=SimpleNamespace( + name=tu.get("name", ""), + arguments=json.dumps(tu.get("input", {})), + ), + )) + + # Build the message object + msg = SimpleNamespace( + role="assistant", + content="\n".join(text_parts) if text_parts else None, + tool_calls=tool_calls if tool_calls else None, + ) + + # Build usage stats + usage_data = response.get("usage", {}) + usage = SimpleNamespace( + prompt_tokens=usage_data.get("inputTokens", 0), + completion_tokens=usage_data.get("outputTokens", 0), + total_tokens=( + usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0) + ), + ) + + finish_reason = _converse_stop_reason_to_openai(stop_reason) + if tool_calls and finish_reason == "stop": + finish_reason = "tool_calls" + + choice = SimpleNamespace( + index=0, + message=msg, + finish_reason=finish_reason, + ) + + return SimpleNamespace( + choices=[choice], + usage=usage, + model=response.get("modelId", ""), + ) + + +# --------------------------------------------------------------------------- +# Streaming response conversion +# --------------------------------------------------------------------------- + +def normalize_converse_stream_events(event_stream) -> SimpleNamespace: + """Consume a Bedrock ConverseStream event stream and build an OpenAI-compatible response. + + Processes the stream events in order: + - ``messageStart`` — role info + - ``contentBlockStart`` — new text or toolUse block + - ``contentBlockDelta`` — incremental text or toolUse input + - ``contentBlockStop`` — block complete + - ``messageStop`` — stop reason + - ``metadata`` — usage stats + + Returns the same shape as ``normalize_converse_response()``. + """ + return stream_converse_with_callbacks(event_stream) + + +def stream_converse_with_callbacks( + event_stream, + on_text_delta=None, + on_tool_start=None, + on_reasoning_delta=None, + on_interrupt_check=None, +) -> SimpleNamespace: + """Process a Bedrock ConverseStream event stream with real-time callbacks. + + This is the core streaming function that powers both the CLI's live token + display and the gateway's progressive message updates. + + Args: + event_stream: The boto3 ``converse_stream()`` response containing a + ``stream`` key with an iterable of events. + on_text_delta: Called with each text chunk as it arrives. Only fires + when no tool_use blocks have been seen (same semantics as the + Anthropic and chat_completions streaming paths). + on_tool_start: Called with the tool name when a toolUse block begins. + Lets the TUI show a spinner while tool arguments are generated. + on_reasoning_delta: Called with reasoning/thinking text chunks. + Bedrock surfaces thinking via ``reasoning`` content block deltas + on supported models (Claude 4.6+). + on_interrupt_check: Called on each event. Should return True if the + agent has been interrupted and streaming should stop. + + Returns: + An OpenAI-compatible SimpleNamespace response, identical in shape to + ``normalize_converse_response()``. + """ + text_parts: List[str] = [] + tool_calls: List[SimpleNamespace] = [] + current_tool: Optional[Dict] = None + current_text_buffer: List[str] = [] + has_tool_use = False + stop_reason = "end_turn" + usage_data: Dict[str, int] = {} + + for event in event_stream.get("stream", []): + # Check for interrupt + if on_interrupt_check and on_interrupt_check(): + break + + if "contentBlockStart" in event: + start = event["contentBlockStart"].get("start", {}) + if "toolUse" in start: + has_tool_use = True + # Flush any accumulated text + if current_text_buffer: + text_parts.append("".join(current_text_buffer)) + current_text_buffer = [] + current_tool = { + "toolUseId": start["toolUse"].get("toolUseId", ""), + "name": start["toolUse"].get("name", ""), + "input_json": "", + } + if on_tool_start: + on_tool_start(current_tool["name"]) + + elif "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + text = delta["text"] + current_text_buffer.append(text) + # Fire text delta callback only when no tool calls are present + # (same semantics as Anthropic/chat_completions streaming) + if on_text_delta and not has_tool_use: + on_text_delta(text) + elif "toolUse" in delta: + if current_tool is not None: + current_tool["input_json"] += delta["toolUse"].get("input", "") + elif "reasoningContent" in delta: + # Claude 4.6+ on Bedrock surfaces thinking via reasoningContent + reasoning = delta["reasoningContent"] + if isinstance(reasoning, dict): + thinking_text = reasoning.get("text", "") + if thinking_text and on_reasoning_delta: + on_reasoning_delta(thinking_text) + + elif "contentBlockStop" in event: + if current_tool is not None: + try: + input_dict = json.loads(current_tool["input_json"]) if current_tool["input_json"] else {} + except (json.JSONDecodeError, TypeError): + input_dict = {} + tool_calls.append(SimpleNamespace( + id=current_tool["toolUseId"], + type="function", + function=SimpleNamespace( + name=current_tool["name"], + arguments=json.dumps(input_dict), + ), + )) + current_tool = None + elif current_text_buffer: + text_parts.append("".join(current_text_buffer)) + current_text_buffer = [] + + elif "messageStop" in event: + stop_reason = event["messageStop"].get("stopReason", "end_turn") + + elif "metadata" in event: + meta_usage = event["metadata"].get("usage", {}) + usage_data = { + "inputTokens": meta_usage.get("inputTokens", 0), + "outputTokens": meta_usage.get("outputTokens", 0), + } + + # Flush remaining text + if current_text_buffer: + text_parts.append("".join(current_text_buffer)) + + msg = SimpleNamespace( + role="assistant", + content="\n".join(text_parts) if text_parts else None, + tool_calls=tool_calls if tool_calls else None, + ) + + usage = SimpleNamespace( + prompt_tokens=usage_data.get("inputTokens", 0), + completion_tokens=usage_data.get("outputTokens", 0), + total_tokens=( + usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0) + ), + ) + + finish_reason = _converse_stop_reason_to_openai(stop_reason) + if tool_calls and finish_reason == "stop": + finish_reason = "tool_calls" + + choice = SimpleNamespace( + index=0, + message=msg, + finish_reason=finish_reason, + ) + + return SimpleNamespace( + choices=[choice], + usage=usage, + model="", + ) + + +# --------------------------------------------------------------------------- +# High-level API: call Bedrock Converse +# --------------------------------------------------------------------------- + +def build_converse_kwargs( + model: str, + messages: List[Dict], + tools: Optional[List[Dict]] = None, + max_tokens: int = 4096, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + stop_sequences: Optional[List[str]] = None, + guardrail_config: Optional[Dict] = None, +) -> Dict[str, Any]: + """Build kwargs for ``bedrock-runtime.converse()`` or ``converse_stream()``. + + Converts OpenAI-format inputs to Converse API parameters. + """ + system_prompt, converse_messages = convert_messages_to_converse(messages) + + kwargs: Dict[str, Any] = { + "modelId": model, + "messages": converse_messages, + "inferenceConfig": { + "maxTokens": max_tokens, + }, + } + + if system_prompt: + kwargs["system"] = system_prompt + + if temperature is not None: + kwargs["inferenceConfig"]["temperature"] = temperature + + if top_p is not None: + kwargs["inferenceConfig"]["topP"] = top_p + + if stop_sequences: + kwargs["inferenceConfig"]["stopSequences"] = stop_sequences + + if tools: + converse_tools = convert_tools_to_converse(tools) + if converse_tools: + # Some Bedrock models don't support tool/function calling (e.g. + # DeepSeek R1, reasoning-only models). Sending toolConfig to + # these models causes a ValidationException → retry loop → failure. + # Strip tools for known non-tool-calling models and warn the user. + # Ref: PR #7920 feedback from @ptlally, pattern from PR #4346. + if _model_supports_tool_use(model): + kwargs["toolConfig"] = {"tools": converse_tools} + else: + logger.warning( + "Model %s does not support tool calling — tools stripped. " + "The agent will operate in text-only mode.", model + ) + + if guardrail_config: + kwargs["guardrailConfig"] = guardrail_config + + return kwargs + + +def call_converse( + region: str, + model: str, + messages: List[Dict], + tools: Optional[List[Dict]] = None, + max_tokens: int = 4096, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + stop_sequences: Optional[List[str]] = None, + guardrail_config: Optional[Dict] = None, +) -> SimpleNamespace: + """Call Bedrock Converse API (non-streaming) and return an OpenAI-compatible response. + + This is the primary entry point for the agent loop when using the Bedrock provider. + """ + client = _get_bedrock_runtime_client(region) + kwargs = build_converse_kwargs( + model=model, + messages=messages, + tools=tools, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + stop_sequences=stop_sequences, + guardrail_config=guardrail_config, + ) + + response = client.converse(**kwargs) + return normalize_converse_response(response) + + +def call_converse_stream( + region: str, + model: str, + messages: List[Dict], + tools: Optional[List[Dict]] = None, + max_tokens: int = 4096, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + stop_sequences: Optional[List[str]] = None, + guardrail_config: Optional[Dict] = None, +) -> SimpleNamespace: + """Call Bedrock ConverseStream API and return an OpenAI-compatible response. + + Consumes the full stream and returns the assembled response. For true + streaming with delta callbacks, use ``iter_converse_stream()`` instead. + """ + client = _get_bedrock_runtime_client(region) + kwargs = build_converse_kwargs( + model=model, + messages=messages, + tools=tools, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + stop_sequences=stop_sequences, + guardrail_config=guardrail_config, + ) + + response = client.converse_stream(**kwargs) + return normalize_converse_stream_events(response) + + +# --------------------------------------------------------------------------- +# Model discovery +# --------------------------------------------------------------------------- + +_discovery_cache: Dict[str, Any] = {} +_DISCOVERY_CACHE_TTL_SECONDS = 3600 + + +def reset_discovery_cache(): + """Clear the model discovery cache. Used in tests.""" + _discovery_cache.clear() + + +def discover_bedrock_models( + region: str, + provider_filter: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + """Discover available Bedrock foundation models and inference profiles. + + Returns a list of model info dicts with keys: + - ``id``: Model ID (e.g. "anthropic.claude-sonnet-4-6-20250514-v1:0") + - ``name``: Human-readable name + - ``provider``: Model provider (e.g. "Anthropic", "Amazon", "Meta") + - ``input_modalities``: List of input types (e.g. ["TEXT", "IMAGE"]) + - ``output_modalities``: List of output types + - ``streaming``: Whether streaming is supported + + Caches results for 1 hour per region to avoid repeated API calls. + + Mirrors OpenClaw's ``discoverBedrockModels()`` in + ``extensions/amazon-bedrock/discovery.ts``. + """ + import time + + cache_key = f"{region}:{','.join(sorted(provider_filter or []))}" + cached = _discovery_cache.get(cache_key) + if cached and (time.time() - cached["timestamp"]) < _DISCOVERY_CACHE_TTL_SECONDS: + return cached["models"] + + try: + client = _get_bedrock_control_client(region) + except Exception as e: + logger.warning("Failed to create Bedrock client for model discovery: %s", e) + return [] + + models = [] + seen_ids = set() + filter_set = {f.lower() for f in (provider_filter or [])} + + # 1. Discover foundation models + try: + response = client.list_foundation_models() + for summary in response.get("modelSummaries", []): + model_id = (summary.get("modelId") or "").strip() + if not model_id: + continue + + # Apply provider filter + if filter_set: + provider_name = (summary.get("providerName") or "").lower() + model_prefix = model_id.split(".")[0].lower() if "." in model_id else "" + if provider_name not in filter_set and model_prefix not in filter_set: + continue + + # Only include active, streaming-capable, text-output models + lifecycle = summary.get("modelLifecycle", {}) + if lifecycle.get("status", "").upper() != "ACTIVE": + continue + if not summary.get("responseStreamingSupported", False): + continue + output_mods = summary.get("outputModalities", []) + if "TEXT" not in output_mods: + continue + + models.append({ + "id": model_id, + "name": (summary.get("modelName") or model_id).strip(), + "provider": (summary.get("providerName") or "").strip(), + "input_modalities": summary.get("inputModalities", []), + "output_modalities": output_mods, + "streaming": True, + }) + seen_ids.add(model_id.lower()) + except Exception as e: + logger.warning("Failed to list Bedrock foundation models: %s", e) + + # 2. Discover inference profiles (cross-region, better capacity) + try: + profiles = [] + next_token = None + while True: + kwargs = {} + if next_token: + kwargs["nextToken"] = next_token + response = client.list_inference_profiles(**kwargs) + for profile in response.get("inferenceProfileSummaries", []): + profiles.append(profile) + next_token = response.get("nextToken") + if not next_token: + break + + for profile in profiles: + profile_id = (profile.get("inferenceProfileId") or "").strip() + if not profile_id: + continue + if profile.get("status") != "ACTIVE": + continue + if profile_id.lower() in seen_ids: + continue + + # Apply provider filter to underlying models + if filter_set: + profile_models = profile.get("models", []) + matches = any( + _extract_provider_from_arn(m.get("modelArn", "")).lower() in filter_set + for m in profile_models + ) + if not matches: + continue + + models.append({ + "id": profile_id, + "name": (profile.get("inferenceProfileName") or profile_id).strip(), + "provider": "inference-profile", + "input_modalities": ["TEXT"], + "output_modalities": ["TEXT"], + "streaming": True, + }) + seen_ids.add(profile_id.lower()) + except Exception as e: + logger.debug("Skipping inference profile discovery: %s", e) + + # Sort: global cross-region profiles first (recommended), then alphabetical + models.sort(key=lambda m: ( + 0 if m["id"].startswith("global.") else 1, + m["name"].lower(), + )) + + _discovery_cache[cache_key] = { + "timestamp": time.time(), + "models": models, + } + return models + + +def _extract_provider_from_arn(arn: str) -> str: + """Extract the model provider from a Bedrock model ARN. + + Example: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2" + → "anthropic" + """ + match = re.search(r"foundation-model/([^.]+)", arn) + return match.group(1) if match else "" + + +def get_bedrock_model_ids(region: str) -> List[str]: + """Return a flat list of available Bedrock model IDs for the given region. + + Convenience wrapper around ``discover_bedrock_models()`` for use in + the model selection UI. + """ + models = discover_bedrock_models(region) + return [m["id"] for m in models] + + +# --------------------------------------------------------------------------- +# Error classification — Bedrock-specific exceptions +# --------------------------------------------------------------------------- +# Mirrors OpenClaw's classifyFailoverReason() and matchesContextOverflowError() +# in extensions/amazon-bedrock/register.sync.runtime.ts. + +# Patterns that indicate the input context exceeded the model's token limit. +# Used by run_agent.py to trigger context compression instead of retrying. +CONTEXT_OVERFLOW_PATTERNS = [ + re.compile(r"ValidationException.*(?:input is too long|max input token|input token.*exceed)", re.IGNORECASE), + re.compile(r"ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)", re.IGNORECASE), + re.compile(r"ModelStreamErrorException.*(?:Input is too long|too many input tokens)", re.IGNORECASE), +] + +# Patterns for throttling / rate limit errors — should trigger backoff + retry. +THROTTLE_PATTERNS = [ + re.compile(r"ThrottlingException", re.IGNORECASE), + re.compile(r"Too many concurrent requests", re.IGNORECASE), + re.compile(r"ServiceQuotaExceededException", re.IGNORECASE), +] + +# Patterns for transient overload — model is temporarily unavailable. +OVERLOAD_PATTERNS = [ + re.compile(r"ModelNotReadyException", re.IGNORECASE), + re.compile(r"ModelTimeoutException", re.IGNORECASE), + re.compile(r"InternalServerException", re.IGNORECASE), +] + + +def is_context_overflow_error(error_message: str) -> bool: + """Return True if the error indicates the input context was too large. + + When this returns True, the agent should compress context and retry + rather than treating it as a fatal error. + """ + return any(p.search(error_message) for p in CONTEXT_OVERFLOW_PATTERNS) + + +def classify_bedrock_error(error_message: str) -> str: + """Classify a Bedrock error for retry/failover decisions. + + Returns: + - ``"context_overflow"`` — input too long, compress and retry + - ``"rate_limit"`` — throttled, backoff and retry + - ``"overloaded"`` — model temporarily unavailable, retry with delay + - ``"unknown"`` — unclassified error + """ + if is_context_overflow_error(error_message): + return "context_overflow" + if any(p.search(error_message) for p in THROTTLE_PATTERNS): + return "rate_limit" + if any(p.search(error_message) for p in OVERLOAD_PATTERNS): + return "overloaded" + return "unknown" + + +# --------------------------------------------------------------------------- +# Bedrock model context lengths +# --------------------------------------------------------------------------- +# Static fallback table for models where the Bedrock API doesn't expose +# context window sizes. Used by agent/model_metadata.py when dynamic +# detection is unavailable. + +BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = { + # Anthropic Claude models on Bedrock + "anthropic.claude-opus-4-6": 200_000, + "anthropic.claude-sonnet-4-6": 200_000, + "anthropic.claude-sonnet-4-5": 200_000, + "anthropic.claude-haiku-4-5": 200_000, + "anthropic.claude-opus-4": 200_000, + "anthropic.claude-sonnet-4": 200_000, + "anthropic.claude-3-5-sonnet": 200_000, + "anthropic.claude-3-5-haiku": 200_000, + "anthropic.claude-3-opus": 200_000, + "anthropic.claude-3-sonnet": 200_000, + "anthropic.claude-3-haiku": 200_000, + # Amazon Nova + "amazon.nova-pro": 300_000, + "amazon.nova-lite": 300_000, + "amazon.nova-micro": 128_000, + # Meta Llama + "meta.llama4-maverick": 128_000, + "meta.llama4-scout": 128_000, + "meta.llama3-3-70b-instruct": 128_000, + # Mistral + "mistral.mistral-large": 128_000, + # DeepSeek + "deepseek.v3": 128_000, +} + +# Default for unknown Bedrock models +BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000 + + +def get_bedrock_context_length(model_id: str) -> int: + """Look up the context window size for a Bedrock model. + + Uses substring matching so versioned IDs like + ``anthropic.claude-sonnet-4-6-20250514-v1:0`` resolve correctly. + """ + model_lower = model_id.lower() + best_key = "" + best_val = BEDROCK_DEFAULT_CONTEXT_LENGTH + for key, val in BEDROCK_CONTEXT_LENGTHS.items(): + if key in model_lower and len(key) > len(best_key): + best_key = key + best_val = val + return best_val diff --git a/agent/error_classifier.py b/agent/error_classifier.py index e436e5571..fa6a98504 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -112,6 +112,10 @@ _RATE_LIMIT_PATTERNS = [ "please retry after", "resource_exhausted", "rate increased too quickly", # Alibaba/DashScope throttling + # AWS Bedrock throttling + "throttlingexception", + "too many concurrent requests", + "servicequotaexceededexception", ] # Usage-limit patterns that need disambiguation (could be billing OR rate_limit) @@ -171,6 +175,11 @@ _CONTEXT_OVERFLOW_PATTERNS = [ # Chinese error messages (some providers return these) "超过最大长度", "上下文长度", + # AWS Bedrock Converse API error patterns + "input is too long", + "max input token", + "input token", + "exceeds the maximum number of input tokens", ] # Model not found patterns diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 46480da23..a0e3bea8c 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1012,6 +1012,16 @@ def get_model_context_length( if ctx: return ctx + # 4b. AWS Bedrock — use static context length table. + # Bedrock's ListFoundationModels doesn't expose context window sizes, + # so we maintain a curated table in bedrock_adapter.py. + if provider == "bedrock" or (base_url and "bedrock-runtime" in base_url): + try: + from agent.bedrock_adapter import get_bedrock_context_length + return get_bedrock_context_length(model) + except ImportError: + pass # boto3 not installed — fall through to generic resolution + # 5. Provider-aware lookups (before generic OpenRouter cache) # These are provider-specific and take priority over the generic OR cache, # since the same model can have different context limits per provider diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 736c2dc35..29c75b172 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -284,6 +284,80 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { source_url="https://ai.google.dev/pricing", pricing_version="google-pricing-2026-03-16", ), + # AWS Bedrock — pricing per the Bedrock pricing page. + # Bedrock charges the same per-token rates as the model provider but + # through AWS billing. These are the on-demand prices (no commitment). + # Source: https://aws.amazon.com/bedrock/pricing/ + ( + "bedrock", + "anthropic.claude-opus-4-6", + ): PricingEntry( + input_cost_per_million=Decimal("15.00"), + output_cost_per_million=Decimal("75.00"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "anthropic.claude-sonnet-4-6", + ): PricingEntry( + input_cost_per_million=Decimal("3.00"), + output_cost_per_million=Decimal("15.00"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "anthropic.claude-sonnet-4-5", + ): PricingEntry( + input_cost_per_million=Decimal("3.00"), + output_cost_per_million=Decimal("15.00"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "anthropic.claude-haiku-4-5", + ): PricingEntry( + input_cost_per_million=Decimal("0.80"), + output_cost_per_million=Decimal("4.00"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "amazon.nova-pro", + ): PricingEntry( + input_cost_per_million=Decimal("0.80"), + output_cost_per_million=Decimal("3.20"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "amazon.nova-lite", + ): PricingEntry( + input_cost_per_million=Decimal("0.06"), + output_cost_per_million=Decimal("0.24"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), + ( + "bedrock", + "amazon.nova-micro", + ): PricingEntry( + input_cost_per_million=Decimal("0.035"), + output_cost_per_million=Decimal("0.14"), + source="official_docs_snapshot", + source_url="https://aws.amazon.com/bedrock/pricing/", + pricing_version="bedrock-pricing-2026-04", + ), } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 1fd9a303c..b75b6b757 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -274,6 +274,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=("XIAOMI_API_KEY",), base_url_env_var="XIAOMI_BASE_URL", ), + "bedrock": ProviderConfig( + id="bedrock", + name="AWS Bedrock", + auth_type="aws_sdk", + inference_base_url="https://bedrock-runtime.us-east-1.amazonaws.com", + api_key_env_vars=(), + base_url_env_var="BEDROCK_BASE_URL", + ), } @@ -924,6 +932,7 @@ def resolve_provider( "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock", "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", # Local server aliases — route through the generic custom provider @@ -980,6 +989,15 @@ def resolve_provider( if has_usable_secret(os.getenv(env_var, "")): return pid + # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars). + # This runs after API-key providers so explicit keys always win. + try: + from agent.bedrock_adapter import has_aws_credentials + if has_aws_credentials(): + return "bedrock" + except ImportError: + pass # boto3 not installed — skip Bedrock auto-detection + raise AuthError( "No inference provider configured. Run 'hermes model' to choose a " "provider and model, or set an API key (OPENROUTER_API_KEY, " @@ -2446,6 +2464,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: pconfig = PROVIDER_REGISTRY.get(target) if pconfig and pconfig.auth_type == "api_key": return get_api_key_provider_status(target) + # AWS SDK providers (Bedrock) — check via boto3 credential chain + if pconfig and pconfig.auth_type == "aws_sdk": + try: + from agent.bedrock_adapter import has_aws_credentials + return {"logged_in": has_aws_credentials(), "provider": target} + except ImportError: + return {"logged_in": False, "provider": target, "error": "boto3 not installed"} return {"logged_in": False} diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index c1cf0ff61..c6e23b42f 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -368,6 +368,27 @@ def _interactive_auth() -> None: print("=" * 50) auth_list_command(SimpleNamespace(provider=None)) + + # Show AWS Bedrock credential status (not in the pool — uses boto3 chain) + try: + from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region + if has_aws_credentials(): + auth_source = resolve_aws_auth_env_var() or "unknown" + region = resolve_bedrock_region() + print(f"bedrock (AWS SDK credential chain):") + print(f" Auth: {auth_source}") + print(f" Region: {region}") + try: + import boto3 + sts = boto3.client("sts", region_name=region) + identity = sts.get_caller_identity() + arn = identity.get("Arn", "unknown") + print(f" Identity: {arn}") + except Exception: + print(f" Identity: (could not resolve — boto3 STS call failed)") + print() + except ImportError: + pass # boto3 or bedrock_adapter not available print() # Main menu diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d06338aa1..71f025adf 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -419,6 +419,27 @@ DEFAULT_CONFIG = { "protect_last_n": 20, # minimum recent messages to keep uncompressed }, + + # AWS Bedrock provider configuration. + # Only used when model.provider is "bedrock". + "bedrock": { + "region": "", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1) + "discovery": { + "enabled": True, # Auto-discover models via ListFoundationModels + "provider_filter": [], # Only show models from these providers (e.g. ["anthropic", "amazon"]) + "refresh_interval": 3600, # Cache discovery results for this many seconds + }, + "guardrail": { + # Amazon Bedrock Guardrails — content filtering and safety policies. + # Create a guardrail in the Bedrock console, then set the ID and version here. + # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html + "guardrail_identifier": "", # e.g. "abc123def456" + "guardrail_version": "", # e.g. "1" or "DRAFT" + "stream_processing_mode": "async", # "sync" or "async" + "trace": "disabled", # "enabled", "disabled", or "enabled_full" + }, + }, + "smart_model_routing": { "enabled": False, "max_simple_chars": 160, @@ -974,6 +995,22 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "AWS_REGION": { + "description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)", + "prompt": "AWS Region", + "url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html", + "password": False, + "category": "provider", + "advanced": True, + }, + "AWS_PROFILE": { + "description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)", + "prompt": "AWS Profile", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, # ── Tool API keys ── "EXA_API_KEY": { diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 69a24aff5..70bd9d0e0 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -860,6 +860,31 @@ def run_doctor(args): except Exception as _e: print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} ") + # -- AWS Bedrock -- + # Bedrock uses the AWS SDK credential chain, not API keys. + try: + from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region + if has_aws_credentials(): + _auth_var = resolve_aws_auth_env_var() + _region = resolve_bedrock_region() + _label = "AWS Bedrock".ljust(20) + print(f" Checking AWS Bedrock...", end="", flush=True) + try: + import boto3 + _br_client = boto3.client("bedrock", region_name=_region) + _br_resp = _br_client.list_foundation_models() + _model_count = len(_br_resp.get("modelSummaries", [])) + print(f"\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} ") + except ImportError: + print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color('(boto3 not installed — pip install hermes-agent[bedrock])', Colors.DIM)} ") + issues.append("Install boto3 for Bedrock: pip install hermes-agent[bedrock]") + except Exception as _e: + _err_name = type(_e).__name__ + print(f"\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} ") + issues.append(f"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels") + except ImportError: + pass # bedrock_adapter not available — skip silently + # ========================================================================= # Check: Submodules # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2eb47aa54..638f2a31c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1139,6 +1139,8 @@ def select_provider_and_model(args=None): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _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"): _model_flow_api_key_provider(config, selected_provider, current_model) @@ -2425,6 +2427,252 @@ def _model_flow_kimi(config, current_model=""): print("No change.") +def _model_flow_bedrock_api_key(config, region, current_model=""): + """Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint. + + 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.models import _PROVIDER_MODELS + + mantle_base_url = f"https://bedrock-mantle.{region}.api.aws/v1" + + # Prompt for API key + existing_key = get_env_value("AWS_BEARER_TOKEN_BEDROCK") or "" + if existing_key: + print(f" Bedrock API Key: {existing_key[:12]}... ✓") + else: + print(f" Endpoint: {mantle_base_url}") + print() + try: + import getpass + api_key = getpass.getpass(" Bedrock API Key: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not api_key: + print(" Cancelled.") + return + save_env_value("AWS_BEARER_TOKEN_BEDROCK", api_key) + existing_key = api_key + print(" ✓ API key saved.") + print() + + # Model selection — use static list (mantle doesn't need boto3 for discovery) + model_list = _PROVIDER_MODELS.get("bedrock", []) + print(f" Showing {len(model_list)} curated models") + + if model_list: + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input(" Model ID: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + _save_model_choice(selected) + + # Save as custom provider pointing to bedrock-mantle + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = "custom" + model["base_url"] = mantle_base_url + model.pop("api_mode", None) # chat_completions is the default + + # Also save region in bedrock config for reference + bedrock_cfg = cfg.get("bedrock", {}) + if not isinstance(bedrock_cfg, dict): + bedrock_cfg = {} + bedrock_cfg["region"] = region + cfg["bedrock"] = bedrock_cfg + + # Save the API key env var name so hermes knows where to find it + save_env_value("OPENAI_API_KEY", existing_key) + save_env_value("OPENAI_BASE_URL", mantle_base_url) + + save_config(cfg) + deactivate_provider() + + print(f" Default model set to: {selected} (via Bedrock API Key, {region})") + print(f" Endpoint: {mantle_base_url}") + else: + print(" No change.") + + +def _model_flow_bedrock(config, current_model=""): + """AWS Bedrock provider: verify credentials, pick region, discover models. + + Uses the native Converse API via boto3 — not the OpenAI-compatible endpoint. + 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.config import load_config, save_config + from hermes_cli.models import _PROVIDER_MODELS + + # 1. Check for AWS credentials + try: + from agent.bedrock_adapter import ( + has_aws_credentials, + resolve_aws_auth_env_var, + resolve_bedrock_region, + discover_bedrock_models, + ) + except ImportError: + print(" ✗ boto3 is not installed. Install it with:") + print(" pip install boto3") + print() + return + + if not has_aws_credentials(): + print(" ⚠ No AWS credentials detected via environment variables.") + print(" Bedrock will use boto3's default credential chain (IMDS, SSO, etc.)") + print() + + auth_var = resolve_aws_auth_env_var() + if auth_var: + print(f" AWS credentials: {auth_var} ✓") + else: + print(" AWS credentials: boto3 default chain (instance role / SSO)") + print() + + # 2. Region selection + current_region = resolve_bedrock_region() + try: + region_input = input(f" AWS Region [{current_region}]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + region = region_input or current_region + + # 2b. Authentication mode + print(" Choose authentication method:") + print() + print(" 1. IAM credential chain (recommended)") + print(" Works with EC2 instance roles, SSO, env vars, aws configure") + print(" 2. Bedrock API Key") + print(" Enter your Bedrock API Key directly — also supports") + print(" team scenarios where an admin distributes keys") + print() + try: + auth_choice = input(" Choice [1]: ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + + if auth_choice == "2": + _model_flow_bedrock_api_key(config, region, current_model) + return + + # 3. Model discovery — try live API first, fall back to static list + print(f" Discovering models in {region}...") + live_models = discover_bedrock_models(region) + + if live_models: + _EXCLUDE_PREFIXES = ( + "stability.", "cohere.embed", "twelvelabs.", "us.stability.", + "us.cohere.embed", "us.twelvelabs.", "global.cohere.embed", + "global.twelvelabs.", + ) + _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") + filtered = [] + for m in live_models: + mid = m["id"] + if any(mid.startswith(p) for p in _EXCLUDE_PREFIXES): + continue + if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS): + continue + filtered.append(m) + + # Deduplicate: prefer inference profiles (us.*, global.*) over bare + # foundation model IDs. + profile_base_ids = set() + for m in filtered: + mid = m["id"] + if mid.startswith(("us.", "global.")): + base = mid.split(".", 1)[1] if "." in mid[3:] else mid + profile_base_ids.add(base) + + deduped = [] + for m in filtered: + mid = m["id"] + if not mid.startswith(("us.", "global.")) and mid in profile_base_ids: + continue + deduped.append(m) + + _RECOMMENDED = [ + "us.anthropic.claude-sonnet-4-6", + "us.anthropic.claude-opus-4-6", + "us.anthropic.claude-haiku-4-5", + "us.amazon.nova-pro", + "us.amazon.nova-lite", + "us.amazon.nova-micro", + "deepseek.v3", + "us.meta.llama4-maverick", + "us.meta.llama4-scout", + ] + + def _sort_key(m): + mid = m["id"] + for i, rec in enumerate(_RECOMMENDED): + if mid.startswith(rec): + return (0, i, mid) + if mid.startswith("global."): + return (1, 0, mid) + return (2, 0, mid) + + 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)") + else: + model_list = _PROVIDER_MODELS.get("bedrock", []) + if model_list: + print(f" Using {len(model_list)} curated models (live discovery unavailable)") + else: + print(" No models found. Check IAM permissions for bedrock:ListFoundationModels.") + return + + # 4. Model selection + if model_list: + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input(" Model ID: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = "bedrock" + model["base_url"] = f"https://bedrock-runtime.{region}.amazonaws.com" + model.pop("api_mode", None) # bedrock_converse is auto-detected + + bedrock_cfg = cfg.get("bedrock", {}) + if not isinstance(bedrock_cfg, dict): + bedrock_cfg = {} + bedrock_cfg["region"] = region + cfg["bedrock"] = bedrock_cfg + + save_config(cfg) + deactivate_provider() + + print(f" Default model set to: {selected} (via AWS Bedrock, {region})") + else: + print(" No change.") + + 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 ( diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 62c215042..9fc68933e 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -303,6 +303,22 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "XiaomiMiMo/MiMo-V2-Flash", "moonshotai/Kimi-K2-Thinking", ], + # AWS Bedrock — static fallback list used when dynamic discovery is + # unavailable (no boto3, no credentials, or API error). The agent + # prefers live discovery via ListFoundationModels + ListInferenceProfiles. + # Use inference profile IDs (us.*) since most models require them. + "bedrock": [ + "us.anthropic.claude-sonnet-4-6", + "us.anthropic.claude-opus-4-6-v1", + "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.amazon.nova-pro-v1:0", + "us.amazon.nova-lite-v1:0", + "us.amazon.nova-micro-v1:0", + "deepseek.v3.2", + "us.meta.llama4-maverick-17b-instruct-v1:0", + "us.meta.llama4-scout-17b-instruct-v1:0", + ], } # --------------------------------------------------------------------------- @@ -536,6 +552,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"), ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"), + ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"), ] # Derived dicts — used throughout the codebase @@ -587,6 +604,10 @@ _PROVIDER_ALIASES = { "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "aws": "bedrock", + "aws-bedrock": "bedrock", + "amazon-bedrock": "bedrock", + "amazon": "bedrock", "grok": "xai", "x-ai": "xai", "x.ai": "xai", @@ -1957,6 +1978,42 @@ def validate_requested_model( # api_models is None — couldn't reach API. Accept and persist, # but warn so typos don't silently break things. + + # Bedrock: use our own discovery instead of HTTP /models endpoint. + # Bedrock's bedrock-runtime URL doesn't support /models — it uses the + # AWS SDK control plane (ListFoundationModels + ListInferenceProfiles). + if normalized == "bedrock": + try: + from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region + region = resolve_bedrock_region() + discovered = discover_bedrock_models(region) + discovered_ids = {m["id"] for m in discovered} + if requested in discovered_ids: + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + # Not in discovered list — still accept (user may have custom + # inference profiles or cross-account access), but warn. + suggestions = get_close_matches(requested, list(discovered_ids), n=3, cutoff=0.4) + 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 Bedrock model discovery for {region}. " + f"It may still work with custom inference profiles or cross-account access." + f"{suggestion_text}" + ), + } + except Exception: + pass # Fall through to generic warning + provider_label = _PROVIDER_LABELS.get(normalized, normalized) return { "accepted": True, diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 6fb940d31..8311e3652 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -236,6 +236,12 @@ ALIASES: Dict[str, str] = { "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + # bedrock + "aws": "bedrock", + "aws-bedrock": "bedrock", + "amazon-bedrock": "bedrock", + "amazon": "bedrock", + # arcee "arcee-ai": "arcee", "arceeai": "arcee", @@ -262,6 +268,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "copilot-acp": "GitHub Copilot ACP", "xiaomi": "Xiaomi MiMo", "local": "Local endpoint", + "bedrock": "AWS Bedrock", } @@ -271,6 +278,7 @@ TRANSPORT_TO_API_MODE: Dict[str, str] = { "openai_chat": "chat_completions", "anthropic_messages": "anthropic_messages", "codex_responses": "codex_responses", + "bedrock_converse": "bedrock_converse", } @@ -388,6 +396,10 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: if pdef is not None: return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") + # Direct provider checks for providers not in HERMES_OVERLAYS + if provider == "bedrock": + return "bedrock_converse" + # URL-based heuristics for custom / unknown providers if base_url: url_lower = base_url.rstrip("/").lower() @@ -395,6 +407,8 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: return "anthropic_messages" if "api.openai.com" in url_lower: return "codex_responses" + if "bedrock-runtime" in url_lower and "amazonaws.com" in url_lower: + return "bedrock_converse" return "chat_completions" diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index b2dec61cd..bdfcfb09d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -124,7 +124,7 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: return "chat_completions" -_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"} +_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"} def _parse_api_mode(raw: Any) -> Optional[str]: @@ -836,6 +836,77 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } + # AWS Bedrock (native Converse API via boto3) + if provider == "bedrock": + from agent.bedrock_adapter import ( + has_aws_credentials, + resolve_aws_auth_env_var, + resolve_bedrock_region, + is_anthropic_bedrock_model, + ) + # When the user explicitly selected bedrock (not auto-detected), + # trust boto3's credential chain — it handles IMDS, ECS task roles, + # Lambda execution roles, SSO, and other implicit sources that our + # env-var check can't detect. + is_explicit = requested_provider in ("bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon") + if not is_explicit and not has_aws_credentials(): + raise AuthError( + "No AWS credentials found for Bedrock. Configure one of:\n" + " - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY\n" + " - AWS_PROFILE (for SSO / named profiles)\n" + " - IAM instance role (EC2, ECS, Lambda)\n" + "Or run 'aws configure' to set up credentials.", + code="no_aws_credentials", + ) + # Read bedrock-specific config from config.yaml + from hermes_cli.config import load_config as _load_bedrock_config + _bedrock_cfg = _load_bedrock_config().get("bedrock", {}) + # Region priority: config.yaml bedrock.region → env var → us-east-1 + region = (_bedrock_cfg.get("region") or "").strip() or resolve_bedrock_region() + auth_source = resolve_aws_auth_env_var() or "aws-sdk-default-chain" + # Build guardrail config if configured + _gr = _bedrock_cfg.get("guardrail", {}) + guardrail_config = None + if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): + guardrail_config = { + "guardrailIdentifier": _gr["guardrail_identifier"], + "guardrailVersion": _gr["guardrail_version"], + } + if _gr.get("stream_processing_mode"): + guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"] + if _gr.get("trace"): + guardrail_config["trace"] = _gr["trace"] + # Dual-path routing: Claude models use AnthropicBedrock SDK for full + # feature parity (prompt caching, thinking budgets, adaptive thinking). + # Non-Claude models use the Converse API for multi-model support. + _current_model = str(model_cfg.get("default") or "").strip() + if is_anthropic_bedrock_model(_current_model): + # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path + runtime = { + "provider": "bedrock", + "api_mode": "anthropic_messages", + "base_url": f"https://bedrock-runtime.{region}.amazonaws.com", + "api_key": "aws-sdk", + "source": auth_source, + "region": region, + "bedrock_anthropic": True, # Signal to use AnthropicBedrock client + "requested_provider": requested_provider, + } + else: + # Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API + runtime = { + "provider": "bedrock", + "api_mode": "bedrock_converse", + "base_url": f"https://bedrock-runtime.{region}.amazonaws.com", + "api_key": "aws-sdk", + "source": auth_source, + "region": region, + "requested_provider": requested_provider, + } + if guardrail_config: + runtime["guardrail_config"] = guardrail_config + return runtime + # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": diff --git a/pyproject.toml b/pyproject.toml index fa3fd4822..0d84b5e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ homeassistant = ["aiohttp>=3.9.0,<4"] sms = ["aiohttp>=3.9.0,<4"] acp = ["agent-client-protocol>=0.9.0,<1.0"] mistral = ["mistralai>=2.3.0,<3"] +bedrock = ["boto3>=1.35.0,<2"] termux = [ # Tested Android / Termux path: keeps the core CLI feature-rich while # avoiding extras that currently depend on non-Android wheels (notably @@ -108,6 +109,7 @@ all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[mistral]", + "hermes-agent[bedrock]", "hermes-agent[web]", ] diff --git a/run_agent.py b/run_agent.py index d229dcfe0..6033da341 100644 --- a/run_agent.py +++ b/run_agent.py @@ -685,7 +685,7 @@ class AIAgent: self.provider = provider_name or "" self.acp_command = acp_command or command self.acp_args = list(acp_args or args or []) - if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}: + if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}: self.api_mode = api_mode elif self.provider == "openai-codex": self.api_mode = "codex_responses" @@ -700,6 +700,9 @@ class AIAgent: # use a URL convention ending in /anthropic. Auto-detect these so the # Anthropic Messages API adapter is used instead of chat completions. self.api_mode = "anthropic_messages" + elif self.provider == "bedrock" or "bedrock-runtime" in self._base_url_lower: + # AWS Bedrock — auto-detect from provider name or base URL. + self.api_mode = "bedrock_converse" else: self.api_mode = "chat_completions" @@ -892,24 +895,70 @@ class AIAgent: if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token - # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. - # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key. - # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401). - _is_native_anthropic = self.provider == "anthropic" - effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") - self.api_key = effective_key - self._anthropic_api_key = effective_key - self._anthropic_base_url = base_url - from agent.anthropic_adapter import _is_oauth_token as _is_oat - self._is_anthropic_oauth = _is_oat(effective_key) - self._anthropic_client = build_anthropic_client(effective_key, base_url) - # No OpenAI client needed for Anthropic mode + # Bedrock + Claude → use AnthropicBedrock SDK for full feature parity + # (prompt caching, thinking budgets, adaptive thinking). + _is_bedrock_anthropic = self.provider == "bedrock" + if _is_bedrock_anthropic: + from agent.anthropic_adapter import build_anthropic_bedrock_client + import re as _re + _region_match = _re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") + _br_region = _region_match.group(1) if _region_match else "us-east-1" + self._bedrock_region = _br_region + self._anthropic_client = build_anthropic_bedrock_client(_br_region) + self._anthropic_api_key = "aws-sdk" + self._anthropic_base_url = base_url + self._is_anthropic_oauth = False + self.api_key = "aws-sdk" + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})") + else: + # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. + # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key. + # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401). + _is_native_anthropic = self.provider == "anthropic" + effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") + self.api_key = effective_key + self._anthropic_api_key = effective_key + self._anthropic_base_url = base_url + from agent.anthropic_adapter import _is_oauth_token as _is_oat + self._is_anthropic_oauth = _is_oat(effective_key) + self._anthropic_client = build_anthropic_client(effective_key, base_url) + # No OpenAI client needed for Anthropic mode + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") + if effective_key and len(effective_key) > 12: + print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + elif self.api_mode == "bedrock_converse": + # AWS Bedrock — uses boto3 directly, no OpenAI client needed. + # Region is extracted from the base_url or defaults to us-east-1. + import re as _re + _region_match = _re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") + self._bedrock_region = _region_match.group(1) if _region_match else "us-east-1" + # Guardrail config — read from config.yaml at init time. + self._bedrock_guardrail_config = None + try: + from hermes_cli.config import load_config as _load_br_cfg + _gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {}) + if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): + self._bedrock_guardrail_config = { + "guardrailIdentifier": _gr["guardrail_identifier"], + "guardrailVersion": _gr["guardrail_version"], + } + if _gr.get("stream_processing_mode"): + self._bedrock_guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"] + if _gr.get("trace"): + self._bedrock_guardrail_config["trace"] = _gr["trace"] + except Exception: + pass self.client = None self._client_kwargs = {} if not self.quiet_mode: - print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") - if effective_key and len(effective_key) > 12: - print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + _gr_label = " + Guardrails" if self._bedrock_guardrail_config else "" + print(f"🤖 AI Agent initialized with model: {self.model} (AWS Bedrock, {self._bedrock_region}{_gr_label})") else: if api_key and base_url: # Explicit credentials from CLI/gateway — construct directly. @@ -4896,6 +4945,17 @@ class AIAgent: ) elif self.api_mode == "anthropic_messages": result["response"] = self._anthropic_messages_create(api_kwargs) + elif self.api_mode == "bedrock_converse": + # Bedrock uses boto3 directly — no OpenAI client needed. + from agent.bedrock_adapter import ( + _get_bedrock_runtime_client, + normalize_converse_response, + ) + region = api_kwargs.pop("__bedrock_region__", "us-east-1") + api_kwargs.pop("__bedrock_converse__", None) + client = _get_bedrock_runtime_client(region) + raw_response = client.converse(**api_kwargs) + result["response"] = normalize_converse_response(raw_response) else: request_client_holder["client"] = self._create_request_openai_client(reason="chat_completion_request") result["response"] = request_client_holder["client"].chat.completions.create(**api_kwargs) @@ -5135,6 +5195,65 @@ class AIAgent: finally: self._codex_on_first_delta = None + # Bedrock Converse uses boto3's converse_stream() with real-time delta + # callbacks — same UX as Anthropic and chat_completions streaming. + if self.api_mode == "bedrock_converse": + result = {"response": None, "error": None} + first_delta_fired = {"done": False} + deltas_were_sent = {"yes": False} + + def _fire_first(): + if not first_delta_fired["done"] and on_first_delta: + first_delta_fired["done"] = True + try: + on_first_delta() + except Exception: + pass + + def _bedrock_call(): + try: + from agent.bedrock_adapter import ( + _get_bedrock_runtime_client, + stream_converse_with_callbacks, + ) + region = api_kwargs.pop("__bedrock_region__", "us-east-1") + api_kwargs.pop("__bedrock_converse__", None) + client = _get_bedrock_runtime_client(region) + raw_response = client.converse_stream(**api_kwargs) + + def _on_text(text): + _fire_first() + self._fire_stream_delta(text) + deltas_were_sent["yes"] = True + + def _on_tool(name): + _fire_first() + self._fire_tool_gen_started(name) + + def _on_reasoning(text): + _fire_first() + self._fire_reasoning_delta(text) + + result["response"] = stream_converse_with_callbacks( + raw_response, + on_text_delta=_on_text if self._has_stream_consumers() else None, + on_tool_start=_on_tool, + on_reasoning_delta=_on_reasoning if self.reasoning_callback or self.stream_delta_callback else None, + on_interrupt_check=lambda: self._interrupt_requested, + ) + except Exception as e: + result["error"] = e + + t = threading.Thread(target=_bedrock_call, daemon=True) + t.start() + while t.is_alive(): + t.join(timeout=0.3) + if self._interrupt_requested: + raise InterruptedError("Agent interrupted during Bedrock API call") + if result["error"] is not None: + raise result["error"] + return result["response"] + result = {"response": None, "error": None} request_client_holder = {"client": None} first_delta_fired = {"done": False} @@ -5765,6 +5884,8 @@ class AIAgent: # provider-specific exceptions like Copilot gpt-5-mini on # chat completions. fb_api_mode = "codex_responses" + elif fb_provider == "bedrock" or "bedrock-runtime" in fb_base_url.lower(): + fb_api_mode = "bedrock_converse" old_model = self.model self.model = fb_model @@ -6244,6 +6365,25 @@ class AIAgent: fast_mode=(self.request_overrides or {}).get("speed") == "fast", ) + # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. + # The adapter handles message/tool conversion and boto3 calls directly. + if self.api_mode == "bedrock_converse": + from agent.bedrock_adapter import build_converse_kwargs + region = getattr(self, "_bedrock_region", None) or "us-east-1" + guardrail = getattr(self, "_bedrock_guardrail_config", None) + return { + "__bedrock_converse__": True, + "__bedrock_region__": region, + **build_converse_kwargs( + model=self.model, + messages=api_messages, + tools=self.tools, + max_tokens=self.max_tokens or 4096, + temperature=None, # Let the model use its default + guardrail_config=guardrail, + ), + } + if self.api_mode == "codex_responses": instructions = "" payload_messages = api_messages @@ -8821,7 +8961,7 @@ class AIAgent: # targeted error instead of wasting 3 API calls. _trunc_content = None _trunc_has_tool_calls = False - if self.api_mode == "chat_completions": + if self.api_mode in ("chat_completions", "bedrock_converse"): _trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None _trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None _trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False @@ -8890,7 +9030,7 @@ class AIAgent: "error": _exhaust_error, } - if self.api_mode == "chat_completions": + if self.api_mode in ("chat_completions", "bedrock_converse"): assistant_message = response.choices[0].message if not assistant_message.tool_calls: length_continue_retries += 1 @@ -8930,7 +9070,7 @@ class AIAgent: "error": "Response remained truncated after 3 continuation attempts", } - if self.api_mode == "chat_completions": + if self.api_mode in ("chat_completions", "bedrock_converse"): assistant_message = response.choices[0].message if assistant_message.tool_calls: if truncated_tool_call_retries < 1: diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py new file mode 100644 index 000000000..d12be7b88 --- /dev/null +++ b/tests/agent/test_bedrock_adapter.py @@ -0,0 +1,1232 @@ +"""Tests for the AWS Bedrock Converse API adapter. + +Covers: + - AWS credential detection and region resolution + - Message format conversion (OpenAI → Converse and back) + - Tool definition conversion + - Response normalization (non-streaming and streaming) + - Model discovery with caching + - Edge cases: empty messages, consecutive roles, image content +""" + +import json +import os +import time +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + + +# --------------------------------------------------------------------------- +# AWS credential detection +# --------------------------------------------------------------------------- + +class TestResolveAwsAuthEnvVar: + """Test AWS credential environment variable detection. + + Mirrors OpenClaw's resolveAwsSdkEnvVarName() priority order. + """ + + def test_prefers_bearer_token_over_access_keys_and_profile(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = { + "AWS_BEARER_TOKEN_BEDROCK": "bearer-token", + "AWS_ACCESS_KEY_ID": "AKIA...", + "AWS_SECRET_ACCESS_KEY": "secret", + "AWS_PROFILE": "default", + } + assert resolve_aws_auth_env_var(env) == "AWS_BEARER_TOKEN_BEDROCK" + + def test_uses_access_keys_when_bearer_token_missing(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = { + "AWS_ACCESS_KEY_ID": "AKIA...", + "AWS_SECRET_ACCESS_KEY": "secret", + "AWS_PROFILE": "default", + } + assert resolve_aws_auth_env_var(env) == "AWS_ACCESS_KEY_ID" + + def test_requires_both_access_key_and_secret(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + # Only access key, no secret → should not match + env = {"AWS_ACCESS_KEY_ID": "AKIA..."} + assert resolve_aws_auth_env_var(env) != "AWS_ACCESS_KEY_ID" + + def test_uses_profile_when_no_keys(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = {"AWS_PROFILE": "production"} + assert resolve_aws_auth_env_var(env) == "AWS_PROFILE" + + def test_uses_container_credentials(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/..."} + assert resolve_aws_auth_env_var(env) == "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" + + def test_uses_web_identity(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = {"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token"} + assert resolve_aws_auth_env_var(env) == "AWS_WEB_IDENTITY_TOKEN_FILE" + + def test_returns_none_when_no_aws_auth(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + # Mock botocore to return no credentials (covers EC2 IMDS fallback) + mock_session = MagicMock() + mock_session.get_credentials.return_value = None + with patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): + import botocore.session as _bs + _bs.get_session = MagicMock(return_value=mock_session) + assert resolve_aws_auth_env_var({}) is None + + def test_ignores_whitespace_only_values(self): + from agent.bedrock_adapter import resolve_aws_auth_env_var + env = {"AWS_PROFILE": " ", "AWS_ACCESS_KEY_ID": " "} + mock_session = MagicMock() + mock_session.get_credentials.return_value = None + with patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): + import botocore.session as _bs + _bs.get_session = MagicMock(return_value=mock_session) + assert resolve_aws_auth_env_var(env) is None + + +class TestHasAwsCredentials: + def test_true_with_profile(self): + from agent.bedrock_adapter import has_aws_credentials + assert has_aws_credentials({"AWS_PROFILE": "default"}) is True + + def test_false_with_empty_env(self): + from agent.bedrock_adapter import has_aws_credentials + mock_session = MagicMock() + mock_session.get_credentials.return_value = None + with patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): + import botocore.session as _bs + _bs.get_session = MagicMock(return_value=mock_session) + assert has_aws_credentials({}) is False + + +class TestResolveBedrocRegion: + def test_prefers_aws_region(self): + from agent.bedrock_adapter import resolve_bedrock_region + env = {"AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "us-west-2"} + assert resolve_bedrock_region(env) == "eu-west-1" + + def test_falls_back_to_default_region(self): + from agent.bedrock_adapter import resolve_bedrock_region + env = {"AWS_DEFAULT_REGION": "ap-northeast-1"} + assert resolve_bedrock_region(env) == "ap-northeast-1" + + def test_defaults_to_us_east_1(self): + from agent.bedrock_adapter import resolve_bedrock_region + assert resolve_bedrock_region({}) == "us-east-1" + + +# --------------------------------------------------------------------------- +# Tool conversion +# --------------------------------------------------------------------------- + +class TestConvertToolsToConverse: + """Test OpenAI → Bedrock Converse tool definition conversion.""" + + def test_converts_single_tool(self): + from agent.bedrock_adapter import convert_tools_to_converse + tools = [{ + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file from disk", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path"}, + }, + "required": ["path"], + }, + }, + }] + result = convert_tools_to_converse(tools) + assert len(result) == 1 + spec = result[0]["toolSpec"] + assert spec["name"] == "read_file" + assert spec["description"] == "Read a file from disk" + assert spec["inputSchema"]["json"]["type"] == "object" + assert "path" in spec["inputSchema"]["json"]["properties"] + + def test_converts_multiple_tools(self): + from agent.bedrock_adapter import convert_tools_to_converse + tools = [ + {"type": "function", "function": {"name": "tool_a", "description": "A", "parameters": {}}}, + {"type": "function", "function": {"name": "tool_b", "description": "B", "parameters": {}}}, + ] + result = convert_tools_to_converse(tools) + assert len(result) == 2 + assert result[0]["toolSpec"]["name"] == "tool_a" + assert result[1]["toolSpec"]["name"] == "tool_b" + + def test_empty_tools(self): + from agent.bedrock_adapter import convert_tools_to_converse + assert convert_tools_to_converse([]) == [] + assert convert_tools_to_converse(None) == [] + + def test_missing_parameters_gets_default(self): + from agent.bedrock_adapter import convert_tools_to_converse + tools = [{"type": "function", "function": {"name": "noop", "description": "No-op"}}] + result = convert_tools_to_converse(tools) + schema = result[0]["toolSpec"]["inputSchema"]["json"] + assert schema == {"type": "object", "properties": {}} + + +# --------------------------------------------------------------------------- +# Message conversion: OpenAI → Converse +# --------------------------------------------------------------------------- + +class TestConvertMessagesToConverse: + """Test OpenAI message format → Bedrock Converse format conversion.""" + + def test_extracts_system_prompt(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + ] + system, msgs = convert_messages_to_converse(messages) + assert system is not None + assert len(system) == 1 + assert system[0]["text"] == "You are a helpful assistant." + assert len(msgs) == 1 + assert msgs[0]["role"] == "user" + + def test_user_message_text(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [{"role": "user", "content": "What is 2+2?"}] + system, msgs = convert_messages_to_converse(messages) + assert system is None + assert len(msgs) == 1 + assert msgs[0]["content"][0]["text"] == "What is 2+2?" + + def test_assistant_with_tool_calls(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "user", "content": "Read the file"}, + { + "role": "assistant", + "content": "I'll read that file.", + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "/tmp/test.txt"}', + }, + }], + }, + ] + system, msgs = convert_messages_to_converse(messages) + # 3 messages: user, assistant, trailing user (Converse requires last=user) + assert len(msgs) == 3 + assistant_content = msgs[1]["content"] + # Should have text block + toolUse block + assert any("text" in b for b in assistant_content) + tool_use_blocks = [b for b in assistant_content if "toolUse" in b] + assert len(tool_use_blocks) == 1 + assert tool_use_blocks[0]["toolUse"]["name"] == "read_file" + assert tool_use_blocks[0]["toolUse"]["toolUseId"] == "call_123" + assert tool_use_blocks[0]["toolUse"]["input"] == {"path": "/tmp/test.txt"} + + def test_tool_result_becomes_user_message(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "user", "content": "Read it"}, + {"role": "assistant", "content": None, "tool_calls": [{ + "id": "call_1", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }]}, + {"role": "tool", "tool_call_id": "call_1", "content": "file contents here"}, + ] + system, msgs = convert_messages_to_converse(messages) + # Tool result should be in a user-role message + tool_result_msg = [m for m in msgs if m["role"] == "user" and any( + "toolResult" in b for b in m["content"] + )] + assert len(tool_result_msg) == 1 + tr = [b for b in tool_result_msg[0]["content"] if "toolResult" in b][0] + assert tr["toolResult"]["toolUseId"] == "call_1" + assert tr["toolResult"]["content"][0]["text"] == "file contents here" + + def test_merges_consecutive_user_messages(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "user", "content": "First"}, + {"role": "user", "content": "Second"}, + ] + system, msgs = convert_messages_to_converse(messages) + # Should be merged into one user message (Converse requires alternation) + assert len(msgs) == 1 + assert msgs[0]["role"] == "user" + texts = [b["text"] for b in msgs[0]["content"] if "text" in b] + assert "First" in texts + assert "Second" in texts + + def test_merges_consecutive_assistant_messages(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Part 1"}, + {"role": "assistant", "content": "Part 2"}, + ] + system, msgs = convert_messages_to_converse(messages) + assistant_msgs = [m for m in msgs if m["role"] == "assistant"] + assert len(assistant_msgs) == 1 + + def test_first_message_must_be_user(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "assistant", "content": "I'm ready"}, + {"role": "user", "content": "Go"}, + ] + system, msgs = convert_messages_to_converse(messages) + assert msgs[0]["role"] == "user" + + def test_last_message_must_be_user(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello"}, + ] + system, msgs = convert_messages_to_converse(messages) + assert msgs[-1]["role"] == "user" + + def test_empty_content_gets_placeholder(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [{"role": "user", "content": ""}] + system, msgs = convert_messages_to_converse(messages) + # Empty string should get a space placeholder + assert msgs[0]["content"][0]["text"].strip() != "" or msgs[0]["content"][0]["text"] == " " + + def test_image_data_url_converted(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": { + "url": "data:image/png;base64,iVBORw0KGgo=", + }}, + ], + }] + system, msgs = convert_messages_to_converse(messages) + content = msgs[0]["content"] + assert any("text" in b for b in content) + image_blocks = [b for b in content if "image" in b] + assert len(image_blocks) == 1 + assert image_blocks[0]["image"]["format"] == "png" + + def test_multiple_system_messages_merged(self): + from agent.bedrock_adapter import convert_messages_to_converse + messages = [ + {"role": "system", "content": "Rule 1"}, + {"role": "system", "content": "Rule 2"}, + {"role": "user", "content": "Go"}, + ] + system, msgs = convert_messages_to_converse(messages) + assert system is not None + assert len(system) == 2 + assert system[0]["text"] == "Rule 1" + assert system[1]["text"] == "Rule 2" + + +# --------------------------------------------------------------------------- +# Response normalization: Converse → OpenAI +# --------------------------------------------------------------------------- + +class TestNormalizeConverseResponse: + """Test Bedrock Converse response → OpenAI format conversion.""" + + def test_text_response(self): + from agent.bedrock_adapter import normalize_converse_response + response = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Hello, world!"}], + }, + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 5}, + } + result = normalize_converse_response(response) + assert result.choices[0].message.content == "Hello, world!" + assert result.choices[0].message.tool_calls is None + assert result.choices[0].finish_reason == "stop" + assert result.usage.prompt_tokens == 10 + assert result.usage.completion_tokens == 5 + assert result.usage.total_tokens == 15 + + def test_tool_use_response(self): + from agent.bedrock_adapter import normalize_converse_response + response = { + "output": { + "message": { + "role": "assistant", + "content": [ + {"text": "I'll read that file."}, + { + "toolUse": { + "toolUseId": "call_abc", + "name": "read_file", + "input": {"path": "/tmp/test.txt"}, + }, + }, + ], + }, + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 20, "outputTokens": 15}, + } + result = normalize_converse_response(response) + assert result.choices[0].message.content == "I'll read that file." + assert result.choices[0].finish_reason == "tool_calls" + tool_calls = result.choices[0].message.tool_calls + assert len(tool_calls) == 1 + assert tool_calls[0].id == "call_abc" + assert tool_calls[0].function.name == "read_file" + assert json.loads(tool_calls[0].function.arguments) == {"path": "/tmp/test.txt"} + + def test_multiple_tool_calls(self): + from agent.bedrock_adapter import normalize_converse_response + response = { + "output": { + "message": { + "role": "assistant", + "content": [ + {"toolUse": {"toolUseId": "c1", "name": "tool_a", "input": {}}}, + {"toolUse": {"toolUseId": "c2", "name": "tool_b", "input": {"x": 1}}}, + ], + }, + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 0, "outputTokens": 0}, + } + result = normalize_converse_response(response) + assert len(result.choices[0].message.tool_calls) == 2 + assert result.choices[0].finish_reason == "tool_calls" + + def test_stop_reason_mapping(self): + from agent.bedrock_adapter import _converse_stop_reason_to_openai + assert _converse_stop_reason_to_openai("end_turn") == "stop" + assert _converse_stop_reason_to_openai("stop_sequence") == "stop" + assert _converse_stop_reason_to_openai("tool_use") == "tool_calls" + assert _converse_stop_reason_to_openai("max_tokens") == "length" + assert _converse_stop_reason_to_openai("content_filtered") == "content_filter" + assert _converse_stop_reason_to_openai("guardrail_intervened") == "content_filter" + assert _converse_stop_reason_to_openai("unknown_reason") == "stop" + + def test_empty_content(self): + from agent.bedrock_adapter import normalize_converse_response + response = { + "output": {"message": {"role": "assistant", "content": []}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 0, "outputTokens": 0}, + } + result = normalize_converse_response(response) + assert result.choices[0].message.content is None + assert result.choices[0].message.tool_calls is None + + def test_tool_calls_override_stop_finish_reason(self): + """When tool_calls are present but stopReason is end_turn, finish_reason should be tool_calls.""" + from agent.bedrock_adapter import normalize_converse_response + response = { + "output": { + "message": { + "role": "assistant", + "content": [ + {"toolUse": {"toolUseId": "c1", "name": "t", "input": {}}}, + ], + }, + }, + "stopReason": "end_turn", # Bedrock sometimes sends this with tool_use + "usage": {"inputTokens": 0, "outputTokens": 0}, + } + result = normalize_converse_response(response) + assert result.choices[0].finish_reason == "tool_calls" + + +# --------------------------------------------------------------------------- +# Streaming response normalization +# --------------------------------------------------------------------------- + +class TestNormalizeConverseStreamEvents: + """Test Bedrock ConverseStream event → OpenAI format conversion.""" + + def test_text_stream(self): + from agent.bedrock_adapter import normalize_converse_stream_events + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hello"}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": ", world!"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 5, "outputTokens": 3}}}, + ]} + result = normalize_converse_stream_events(events) + assert result.choices[0].message.content == "Hello, world!" + assert result.choices[0].finish_reason == "stop" + assert result.usage.prompt_tokens == 5 + assert result.usage.completion_tokens == 3 + + def test_tool_use_stream(self): + from agent.bedrock_adapter import normalize_converse_stream_events + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": { + "toolUse": {"toolUseId": "call_1", "name": "read_file"}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": { + "toolUse": {"input": '{"path":'}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": { + "toolUse": {"input": '"/tmp/f"}'}, + }}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "tool_use"}}, + {"metadata": {"usage": {"inputTokens": 10, "outputTokens": 8}}}, + ]} + result = normalize_converse_stream_events(events) + assert result.choices[0].finish_reason == "tool_calls" + tc = result.choices[0].message.tool_calls + assert len(tc) == 1 + assert tc[0].id == "call_1" + assert tc[0].function.name == "read_file" + assert json.loads(tc[0].function.arguments) == {"path": "/tmp/f"} + + def test_mixed_text_and_tool_stream(self): + from agent.bedrock_adapter import normalize_converse_stream_events + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + # Text block + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Let me check."}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + # Tool block + {"contentBlockStart": {"contentBlockIndex": 1, "start": { + "toolUse": {"toolUseId": "c1", "name": "search"}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 1, "delta": { + "toolUse": {"input": '{"q":"test"}'}, + }}}, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "tool_use"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + result = normalize_converse_stream_events(events) + assert result.choices[0].message.content == "Let me check." + assert len(result.choices[0].message.tool_calls) == 1 + + def test_empty_stream(self): + from agent.bedrock_adapter import normalize_converse_stream_events + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + result = normalize_converse_stream_events(events) + assert result.choices[0].message.content is None + assert result.choices[0].message.tool_calls is None + + +# --------------------------------------------------------------------------- +# build_converse_kwargs +# --------------------------------------------------------------------------- + +class TestBuildConverseKwargs: + """Test the high-level kwargs builder for Converse API calls.""" + + def test_basic_kwargs(self): + from agent.bedrock_adapter import build_converse_kwargs + messages = [ + {"role": "system", "content": "Be helpful."}, + {"role": "user", "content": "Hi"}, + ] + kwargs = build_converse_kwargs( + model="anthropic.claude-sonnet-4-6-20250514-v1:0", + messages=messages, + max_tokens=1024, + ) + assert kwargs["modelId"] == "anthropic.claude-sonnet-4-6-20250514-v1:0" + assert kwargs["inferenceConfig"]["maxTokens"] == 1024 + assert kwargs["system"] is not None + assert len(kwargs["messages"]) >= 1 + + def test_includes_tools(self): + from agent.bedrock_adapter import build_converse_kwargs + tools = [{"type": "function", "function": { + "name": "test", "description": "Test", "parameters": {}, + }}] + kwargs = build_converse_kwargs( + model="test-model", messages=[{"role": "user", "content": "Hi"}], + tools=tools, + ) + assert "toolConfig" in kwargs + assert len(kwargs["toolConfig"]["tools"]) == 1 + + def test_includes_temperature_and_top_p(self): + from agent.bedrock_adapter import build_converse_kwargs + kwargs = build_converse_kwargs( + model="test-model", messages=[{"role": "user", "content": "Hi"}], + temperature=0.7, top_p=0.9, + ) + assert kwargs["inferenceConfig"]["temperature"] == 0.7 + assert kwargs["inferenceConfig"]["topP"] == 0.9 + + def test_includes_guardrail_config(self): + from agent.bedrock_adapter import build_converse_kwargs + guardrail = { + "guardrailIdentifier": "gr-123", + "guardrailVersion": "1", + } + kwargs = build_converse_kwargs( + model="test-model", messages=[{"role": "user", "content": "Hi"}], + guardrail_config=guardrail, + ) + assert kwargs["guardrailConfig"] == guardrail + + def test_no_system_when_absent(self): + from agent.bedrock_adapter import build_converse_kwargs + kwargs = build_converse_kwargs( + model="test-model", messages=[{"role": "user", "content": "Hi"}], + ) + assert "system" not in kwargs + + def test_no_tool_config_when_empty(self): + from agent.bedrock_adapter import build_converse_kwargs + kwargs = build_converse_kwargs( + model="test-model", messages=[{"role": "user", "content": "Hi"}], + tools=[], + ) + assert "toolConfig" not in kwargs + + +# --------------------------------------------------------------------------- +# Model discovery +# --------------------------------------------------------------------------- + +class TestDiscoverBedrockModels: + """Test Bedrock model discovery with mocked AWS API calls.""" + + def test_discovers_foundation_models(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [ + { + "modelId": "anthropic.claude-sonnet-4-6-20250514-v1:0", + "modelName": "Claude Sonnet 4.6", + "providerName": "Anthropic", + "inputModalities": ["TEXT", "IMAGE"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }, + { + "modelId": "amazon.nova-pro-v1:0", + "modelName": "Nova Pro", + "providerName": "Amazon", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }, + ], + } + mock_client.list_inference_profiles.return_value = { + "inferenceProfileSummaries": [], + } + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1") + + assert len(models) == 2 + ids = [m["id"] for m in models] + assert "anthropic.claude-sonnet-4-6-20250514-v1:0" in ids + assert "amazon.nova-pro-v1:0" in ids + + def test_filters_inactive_models(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [ + { + "modelId": "old-model", + "modelName": "Old", + "providerName": "Test", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "LEGACY"}, + }, + ], + } + mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1") + + assert len(models) == 0 + + def test_filters_non_streaming_models(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [ + { + "modelId": "embed-model", + "modelName": "Embeddings", + "providerName": "Test", + "inputModalities": ["TEXT"], + "outputModalities": ["EMBEDDING"], + "responseStreamingSupported": False, + "modelLifecycle": {"status": "ACTIVE"}, + }, + ], + } + mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1") + + assert len(models) == 0 + + def test_provider_filter(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [ + { + "modelId": "anthropic.claude-v2", + "modelName": "Claude v2", + "providerName": "Anthropic", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }, + { + "modelId": "amazon.titan-text", + "modelName": "Titan", + "providerName": "Amazon", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }, + ], + } + mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1", provider_filter=["anthropic"]) + + assert len(models) == 1 + assert models[0]["id"] == "anthropic.claude-v2" + + def test_caches_results(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [{ + "modelId": "test-model", + "modelName": "Test", + "providerName": "Test", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }], + } + mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + first = discover_bedrock_models("us-east-1") + second = discover_bedrock_models("us-east-1") + + # Should only call the API once (second call uses cache) + assert mock_client.list_foundation_models.call_count == 1 + assert first == second + + def test_discovers_inference_profiles(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = {"modelSummaries": []} + mock_client.list_inference_profiles.return_value = { + "inferenceProfileSummaries": [ + { + "inferenceProfileId": "us.anthropic.claude-sonnet-4-6", + "inferenceProfileName": "US Claude Sonnet 4.6", + "status": "ACTIVE", + "models": [{"modelArn": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6"}], + }, + ], + } + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1") + + assert len(models) == 1 + assert models[0]["id"] == "us.anthropic.claude-sonnet-4-6" + + def test_global_profiles_sorted_first(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + mock_client = MagicMock() + mock_client.list_foundation_models.return_value = { + "modelSummaries": [{ + "modelId": "anthropic.claude-v2", + "modelName": "Claude v2", + "providerName": "Anthropic", + "inputModalities": ["TEXT"], + "outputModalities": ["TEXT"], + "responseStreamingSupported": True, + "modelLifecycle": {"status": "ACTIVE"}, + }], + } + mock_client.list_inference_profiles.return_value = { + "inferenceProfileSummaries": [{ + "inferenceProfileId": "global.anthropic.claude-v2", + "inferenceProfileName": "Global Claude v2", + "status": "ACTIVE", + "models": [], + }], + } + + with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + models = discover_bedrock_models("us-east-1") + + assert models[0]["id"] == "global.anthropic.claude-v2" + + def test_handles_api_error_gracefully(self): + from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + reset_discovery_cache() + + with patch("agent.bedrock_adapter._get_bedrock_control_client", side_effect=Exception("No creds")): + models = discover_bedrock_models("us-east-1") + + assert models == [] + + +class TestExtractProviderFromArn: + def test_extracts_anthropic(self): + from agent.bedrock_adapter import _extract_provider_from_arn + arn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6" + assert _extract_provider_from_arn(arn) == "anthropic" + + def test_extracts_amazon(self): + from agent.bedrock_adapter import _extract_provider_from_arn + arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0" + assert _extract_provider_from_arn(arn) == "amazon" + + def test_returns_empty_for_invalid_arn(self): + from agent.bedrock_adapter import _extract_provider_from_arn + assert _extract_provider_from_arn("not-an-arn") == "" + assert _extract_provider_from_arn("") == "" + + +# --------------------------------------------------------------------------- +# Client cache management +# --------------------------------------------------------------------------- + +class TestClientCache: + def test_reset_clears_caches(self): + from agent.bedrock_adapter import ( + _bedrock_runtime_client_cache, + _bedrock_control_client_cache, + reset_client_cache, + ) + _bedrock_runtime_client_cache["test"] = "dummy" + _bedrock_control_client_cache["test"] = "dummy" + reset_client_cache() + assert len(_bedrock_runtime_client_cache) == 0 + assert len(_bedrock_control_client_cache) == 0 + + +# --------------------------------------------------------------------------- +# Streaming with callbacks +# --------------------------------------------------------------------------- + +class TestStreamConverseWithCallbacks: + """Test real-time streaming with delta callbacks.""" + + def test_text_deltas_fire_callback(self): + from agent.bedrock_adapter import stream_converse_with_callbacks + deltas = [] + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hello"}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": " world"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 5, "outputTokens": 3}}}, + ]} + result = stream_converse_with_callbacks( + events, on_text_delta=lambda t: deltas.append(t), + ) + assert deltas == ["Hello", " world"] + assert result.choices[0].message.content == "Hello world" + + def test_text_deltas_suppressed_when_tool_use_present(self): + """Text deltas should NOT fire when tool_use blocks are present.""" + from agent.bedrock_adapter import stream_converse_with_callbacks + deltas = [] + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Let me check."}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"contentBlockStart": {"contentBlockIndex": 1, "start": { + "toolUse": {"toolUseId": "c1", "name": "search"}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 1, "delta": { + "toolUse": {"input": '{"q":"test"}'}, + }}}, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "tool_use"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + result = stream_converse_with_callbacks( + events, on_text_delta=lambda t: deltas.append(t), + ) + # Text delta for "Let me check." should fire (before tool_use was seen) + assert "Let me check." in deltas + # But the result should still have both text and tool calls + assert result.choices[0].message.content == "Let me check." + assert len(result.choices[0].message.tool_calls) == 1 + + def test_tool_start_callback_fires(self): + from agent.bedrock_adapter import stream_converse_with_callbacks + tools_started = [] + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": { + "toolUse": {"toolUseId": "c1", "name": "read_file"}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": { + "toolUse": {"input": '{"path":"/tmp/f"}'}, + }}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "tool_use"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + result = stream_converse_with_callbacks( + events, on_tool_start=lambda name: tools_started.append(name), + ) + assert tools_started == ["read_file"] + + def test_interrupt_stops_processing(self): + from agent.bedrock_adapter import stream_converse_with_callbacks + deltas = [] + call_count = {"n": 0} + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "A"}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "B"}}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "C"}}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + + def check_interrupt(): + call_count["n"] += 1 + return call_count["n"] >= 3 # Interrupt after 2 events + + result = stream_converse_with_callbacks( + events, + on_text_delta=lambda t: deltas.append(t), + on_interrupt_check=check_interrupt, + ) + # Should have processed fewer than all deltas + assert len(deltas) < 3 + + def test_reasoning_delta_callback(self): + from agent.bedrock_adapter import stream_converse_with_callbacks + reasoning = [] + events = {"stream": [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": { + "reasoningContent": {"text": "Let me think..."}, + }}}, + {"contentBlockDelta": {"contentBlockIndex": 1, "delta": {"text": "Answer."}}}, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0}}}, + ]} + result = stream_converse_with_callbacks( + events, on_reasoning_delta=lambda t: reasoning.append(t), + ) + assert reasoning == ["Let me think..."] + + +# --------------------------------------------------------------------------- +# Guardrail config in build_converse_kwargs +# --------------------------------------------------------------------------- + +class TestGuardrailConfig: + """Test that guardrail configuration is correctly passed through.""" + + def test_guardrail_included_in_kwargs(self): + from agent.bedrock_adapter import build_converse_kwargs + guardrail = { + "guardrailIdentifier": "gr-abc123", + "guardrailVersion": "1", + "streamProcessingMode": "async", + "trace": "enabled", + } + kwargs = build_converse_kwargs( + model="test-model", + messages=[{"role": "user", "content": "Hi"}], + guardrail_config=guardrail, + ) + assert kwargs["guardrailConfig"] == guardrail + + def test_no_guardrail_when_none(self): + from agent.bedrock_adapter import build_converse_kwargs + kwargs = build_converse_kwargs( + model="test-model", + messages=[{"role": "user", "content": "Hi"}], + guardrail_config=None, + ) + assert "guardrailConfig" not in kwargs + + def test_no_guardrail_when_empty_dict(self): + from agent.bedrock_adapter import build_converse_kwargs + kwargs = build_converse_kwargs( + model="test-model", + messages=[{"role": "user", "content": "Hi"}], + guardrail_config={}, + ) + # Empty dict is falsy, should not be included + assert "guardrailConfig" not in kwargs + + +# --------------------------------------------------------------------------- +# Error classification +# --------------------------------------------------------------------------- + +class TestBedrockErrorClassification: + """Test Bedrock-specific error classification.""" + + def test_context_overflow_validation_exception(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error( + "ValidationException: input is too long for model" + ) == "context_overflow" + + def test_context_overflow_max_tokens(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error( + "ValidationException: exceeds the maximum number of input tokens" + ) == "context_overflow" + + def test_context_overflow_stream_error(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error( + "ModelStreamErrorException: Input is too long" + ) == "context_overflow" + + def test_rate_limit_throttling(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error("ThrottlingException: Rate exceeded") == "rate_limit" + + def test_rate_limit_concurrent(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error("Too many concurrent requests") == "rate_limit" + + def test_overloaded_not_ready(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error("ModelNotReadyException") == "overloaded" + + def test_overloaded_timeout(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error("ModelTimeoutException") == "overloaded" + + def test_unknown_error(self): + from agent.bedrock_adapter import classify_bedrock_error + assert classify_bedrock_error("SomeRandomError: something went wrong") == "unknown" + + +class TestBedrockContextLength: + """Test Bedrock model context length lookup.""" + + def test_claude_opus_4_6(self): + from agent.bedrock_adapter import get_bedrock_context_length + assert get_bedrock_context_length("anthropic.claude-opus-4-6-20250514-v1:0") == 200_000 + + def test_claude_sonnet_versioned(self): + from agent.bedrock_adapter import get_bedrock_context_length + assert get_bedrock_context_length("anthropic.claude-sonnet-4-6-20250514-v1:0") == 200_000 + + def test_nova_pro(self): + from agent.bedrock_adapter import get_bedrock_context_length + assert get_bedrock_context_length("amazon.nova-pro-v1:0") == 300_000 + + def test_nova_micro(self): + from agent.bedrock_adapter import get_bedrock_context_length + assert get_bedrock_context_length("amazon.nova-micro-v1:0") == 128_000 + + def test_unknown_model_gets_default(self): + from agent.bedrock_adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH + assert get_bedrock_context_length("unknown.model-v1:0") == BEDROCK_DEFAULT_CONTEXT_LENGTH + + def test_inference_profile_resolves(self): + from agent.bedrock_adapter import get_bedrock_context_length + # Cross-region inference profiles contain the base model ID + assert get_bedrock_context_length("us.anthropic.claude-sonnet-4-6") == 200_000 + + def test_longest_prefix_wins(self): + from agent.bedrock_adapter import get_bedrock_context_length + # "anthropic.claude-3-5-sonnet" should match before "anthropic.claude-3" + assert get_bedrock_context_length("anthropic.claude-3-5-sonnet-20240620-v1:0") == 200_000 + + +# --------------------------------------------------------------------------- +# Tool-calling capability detection +# --------------------------------------------------------------------------- + +class TestModelSupportsToolUse: + """Test non-tool-calling model detection.""" + + def test_claude_supports_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("us.anthropic.claude-sonnet-4-6") is True + + def test_nova_supports_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("us.amazon.nova-pro-v1:0") is True + + def test_deepseek_v3_supports_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("deepseek.v3.2") is True + + def test_llama_supports_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("us.meta.llama4-scout-17b-instruct-v1:0") is True + + def test_deepseek_r1_no_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("us.deepseek.r1-v1:0") is False + + def test_deepseek_r1_alt_format_no_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("deepseek-r1") is False + + def test_stability_no_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("stability.stable-diffusion-xl") is False + + def test_embedding_no_tools(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("cohere.embed-v4") is False + + def test_unknown_model_defaults_to_true(self): + from agent.bedrock_adapter import _model_supports_tool_use + assert _model_supports_tool_use("some-future-model-v1") is True + + +class TestBuildConverseKwargsToolStripping: + """Test that tools are stripped for non-tool-calling models.""" + + def test_tools_included_for_claude(self): + from agent.bedrock_adapter import build_converse_kwargs + tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}] + kwargs = build_converse_kwargs( + model="us.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hi"}], + tools=tools, + ) + assert "toolConfig" in kwargs + + def test_tools_stripped_for_deepseek_r1(self): + from agent.bedrock_adapter import build_converse_kwargs + tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}] + kwargs = build_converse_kwargs( + model="us.deepseek.r1-v1:0", + messages=[{"role": "user", "content": "Hi"}], + tools=tools, + ) + assert "toolConfig" not in kwargs + + +# --------------------------------------------------------------------------- +# Dual-path model routing +# --------------------------------------------------------------------------- + +class TestIsAnthropicBedrockModel: + """Test Claude model detection for dual-path routing.""" + + def test_us_claude_sonnet(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("us.anthropic.claude-sonnet-4-6") is True + + def test_global_claude_opus(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("global.anthropic.claude-opus-4-6-v1") is True + + def test_bare_claude(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("anthropic.claude-haiku-4-5-20251001-v1:0") is True + + def test_nova_is_not_anthropic(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("us.amazon.nova-pro-v1:0") is False + + def test_deepseek_is_not_anthropic(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("deepseek.v3.2") is False + + def test_llama_is_not_anthropic(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("us.meta.llama4-scout-17b-instruct-v1:0") is False + + def test_mistral_is_not_anthropic(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("mistral.mistral-large-3-675b-instruct") is False + + def test_eu_claude(self): + from agent.bedrock_adapter import is_anthropic_bedrock_model + assert is_anthropic_bedrock_model("eu.anthropic.claude-sonnet-4-6") is True + + +class TestEmptyTextBlockFix: + """Test that empty text blocks are replaced with space placeholders.""" + + def test_none_content_gets_space(self): + from agent.bedrock_adapter import _convert_content_to_converse + blocks = _convert_content_to_converse(None) + assert blocks[0]["text"] == " " + + def test_empty_string_gets_space(self): + from agent.bedrock_adapter import _convert_content_to_converse + blocks = _convert_content_to_converse("") + assert blocks[0]["text"] == " " + + def test_whitespace_only_gets_space(self): + from agent.bedrock_adapter import _convert_content_to_converse + blocks = _convert_content_to_converse(" ") + assert blocks[0]["text"] == " " + + def test_real_text_preserved(self): + from agent.bedrock_adapter import _convert_content_to_converse + blocks = _convert_content_to_converse("Hello") + assert blocks[0]["text"] == "Hello" diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py new file mode 100644 index 000000000..ba77d9361 --- /dev/null +++ b/tests/agent/test_bedrock_integration.py @@ -0,0 +1,269 @@ +"""Integration tests for the AWS Bedrock provider wiring. + +Verifies that the Bedrock provider is correctly registered in the +provider registry, model catalog, and runtime resolution pipeline. +These tests do NOT require AWS credentials or boto3 — all AWS calls +are mocked. + +Note: Tests that import ``hermes_cli.auth`` or ``hermes_cli.runtime_provider`` +require Python 3.10+ due to ``str | None`` type syntax in the import chain. +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + + +class TestProviderRegistry: + """Verify Bedrock is registered in PROVIDER_REGISTRY.""" + + def test_bedrock_in_registry(self): + from hermes_cli.auth import PROVIDER_REGISTRY + assert "bedrock" in PROVIDER_REGISTRY + + def test_bedrock_auth_type_is_aws_sdk(self): + from hermes_cli.auth import PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["bedrock"] + assert pconfig.auth_type == "aws_sdk" + + def test_bedrock_has_no_api_key_env_vars(self): + """Bedrock uses the AWS SDK credential chain, not API keys.""" + from hermes_cli.auth import PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["bedrock"] + assert pconfig.api_key_env_vars == () + + def test_bedrock_base_url_env_var(self): + from hermes_cli.auth import PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY["bedrock"] + assert pconfig.base_url_env_var == "BEDROCK_BASE_URL" + + +class TestProviderAliases: + """Verify Bedrock aliases resolve correctly.""" + + def test_aws_alias(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("aws") == "bedrock" + + def test_aws_bedrock_alias(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("aws-bedrock") == "bedrock" + + def test_amazon_bedrock_alias(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("amazon-bedrock") == "bedrock" + + def test_amazon_alias(self): + from hermes_cli.models import _PROVIDER_ALIASES + assert _PROVIDER_ALIASES.get("amazon") == "bedrock" + + +class TestProviderLabels: + """Verify Bedrock appears in provider labels.""" + + def test_bedrock_label(self): + from hermes_cli.models import _PROVIDER_LABELS + assert _PROVIDER_LABELS.get("bedrock") == "AWS Bedrock" + + +class TestModelCatalog: + """Verify Bedrock has a static model fallback list.""" + + def test_bedrock_has_curated_models(self): + from hermes_cli.models import _PROVIDER_MODELS + models = _PROVIDER_MODELS.get("bedrock", []) + assert len(models) > 0 + + def test_bedrock_models_include_claude(self): + from hermes_cli.models import _PROVIDER_MODELS + models = _PROVIDER_MODELS.get("bedrock", []) + claude_models = [m for m in models if "anthropic.claude" in m] + assert len(claude_models) > 0 + + def test_bedrock_models_include_nova(self): + from hermes_cli.models import _PROVIDER_MODELS + models = _PROVIDER_MODELS.get("bedrock", []) + nova_models = [m for m in models if "amazon.nova" in m] + assert len(nova_models) > 0 + + +class TestResolveProvider: + """Verify resolve_provider() handles bedrock correctly.""" + + def test_explicit_bedrock_resolves(self, monkeypatch): + """When user explicitly requests 'bedrock', it should resolve.""" + from hermes_cli.auth import PROVIDER_REGISTRY + # bedrock is in the registry, so resolve_provider should return it + from hermes_cli.auth import resolve_provider + result = resolve_provider("bedrock") + assert result == "bedrock" + + def test_aws_alias_resolves_to_bedrock(self): + from hermes_cli.auth import resolve_provider + result = resolve_provider("aws") + assert result == "bedrock" + + def test_amazon_bedrock_alias_resolves(self): + from hermes_cli.auth import resolve_provider + result = resolve_provider("amazon-bedrock") + assert result == "bedrock" + + def test_auto_detect_with_aws_credentials(self, monkeypatch): + """When AWS credentials are present and no other provider is configured, + auto-detect should find bedrock.""" + from hermes_cli.auth import resolve_provider + + # Clear all other provider env vars + for var in ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY"]: + monkeypatch.delenv(var, raising=False) + + # Set AWS credentials + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + + # Mock the auth store to have no active provider + with patch("hermes_cli.auth._load_auth_store", return_value={}): + result = resolve_provider("auto") + assert result == "bedrock" + + +class TestRuntimeProvider: + """Verify resolve_runtime_provider() handles bedrock correctly.""" + + def test_bedrock_runtime_resolution(self, monkeypatch): + from hermes_cli.runtime_provider import resolve_runtime_provider + + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + monkeypatch.setenv("AWS_REGION", "eu-west-1") + + # Mock resolve_provider to return bedrock + with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ + patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): + result = resolve_runtime_provider(requested="bedrock") + + assert result["provider"] == "bedrock" + assert result["api_mode"] == "bedrock_converse" + assert result["region"] == "eu-west-1" + assert "bedrock-runtime.eu-west-1.amazonaws.com" in result["base_url"] + assert result["api_key"] == "aws-sdk" + + def test_bedrock_runtime_default_region(self, monkeypatch): + from hermes_cli.runtime_provider import resolve_runtime_provider + + monkeypatch.setenv("AWS_PROFILE", "default") + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + + with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ + patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): + result = resolve_runtime_provider(requested="bedrock") + + assert result["region"] == "us-east-1" + + def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch): + """When bedrock is auto-detected (not explicitly requested) and no + credentials are found, runtime resolution should raise AuthError.""" + from hermes_cli.runtime_provider import resolve_runtime_provider + from hermes_cli.auth import AuthError + + # Clear all AWS env vars + for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE", + "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_WEB_IDENTITY_TOKEN_FILE"]: + monkeypatch.delenv(var, raising=False) + + # Mock both the provider resolution and boto3's credential chain + mock_session = MagicMock() + mock_session.get_credentials.return_value = None + with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ + patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}), \ + patch("hermes_cli.runtime_provider.resolve_requested_provider", return_value="auto"), \ + patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): + import botocore.session as _bs + _bs.get_session = MagicMock(return_value=mock_session) + with pytest.raises(AuthError, match="No AWS credentials"): + resolve_runtime_provider(requested="auto") + + def test_bedrock_runtime_explicit_skips_credential_check(self, monkeypatch): + """When user explicitly requests bedrock, trust boto3's credential chain + even if env-var detection finds nothing (covers IMDS, SSO, etc.).""" + from hermes_cli.runtime_provider import resolve_runtime_provider + + # No AWS env vars set — but explicit bedrock request should not raise + for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE", + "AWS_BEARER_TOKEN_BEDROCK"]: + monkeypatch.delenv(var, raising=False) + + with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ + patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}): + result = resolve_runtime_provider(requested="bedrock") + assert result["provider"] == "bedrock" + assert result["api_mode"] == "bedrock_converse" + + +# --------------------------------------------------------------------------- +# providers.py integration +# --------------------------------------------------------------------------- + +class TestProvidersModule: + """Verify bedrock is wired into hermes_cli/providers.py.""" + + def test_bedrock_alias_in_providers(self): + from hermes_cli.providers import ALIASES + assert ALIASES.get("bedrock") is None # "bedrock" IS the canonical name, not an alias + assert ALIASES.get("aws") == "bedrock" + assert ALIASES.get("aws-bedrock") == "bedrock" + + def test_bedrock_transport_mapping(self): + from hermes_cli.providers import TRANSPORT_TO_API_MODE + assert TRANSPORT_TO_API_MODE.get("bedrock_converse") == "bedrock_converse" + + def test_determine_api_mode_from_bedrock_url(self): + from hermes_cli.providers import determine_api_mode + assert determine_api_mode( + "unknown", "https://bedrock-runtime.us-east-1.amazonaws.com" + ) == "bedrock_converse" + + def test_label_override(self): + from hermes_cli.providers import _LABEL_OVERRIDES + assert _LABEL_OVERRIDES.get("bedrock") == "AWS Bedrock" + + +# --------------------------------------------------------------------------- +# Error classifier integration +# --------------------------------------------------------------------------- + +class TestErrorClassifierBedrock: + """Verify Bedrock error patterns are in the global error classifier.""" + + def test_throttling_in_rate_limit_patterns(self): + from agent.error_classifier import _RATE_LIMIT_PATTERNS + assert "throttlingexception" in _RATE_LIMIT_PATTERNS + + def test_context_overflow_patterns(self): + from agent.error_classifier import _CONTEXT_OVERFLOW_PATTERNS + assert "input is too long" in _CONTEXT_OVERFLOW_PATTERNS + + +# --------------------------------------------------------------------------- +# pyproject.toml bedrock extra +# --------------------------------------------------------------------------- + +class TestPackaging: + """Verify bedrock optional dependency is declared.""" + + def test_bedrock_extra_exists(self): + import configparser + from pathlib import Path + # Read pyproject.toml to verify [bedrock] extra + toml_path = Path(__file__).parent.parent.parent / "pyproject.toml" + content = toml_path.read_text() + assert 'bedrock = ["boto3' in content + + def test_bedrock_in_all_extra(self): + from pathlib import Path + content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() + assert '"hermes-agent[bedrock]"' in content diff --git a/website/docs/guides/aws-bedrock.md b/website/docs/guides/aws-bedrock.md new file mode 100644 index 000000000..cf5aec4e3 --- /dev/null +++ b/website/docs/guides/aws-bedrock.md @@ -0,0 +1,164 @@ +--- +sidebar_position: 14 +title: "AWS Bedrock" +description: "Use Hermes Agent with Amazon Bedrock — native Converse API, IAM authentication, Guardrails, and cross-region inference" +--- + +# AWS Bedrock + +Hermes Agent supports Amazon Bedrock as a native provider using the **Converse API** — not the OpenAI-compatible endpoint. This gives you full access to the Bedrock ecosystem: IAM authentication, Guardrails, cross-region inference profiles, and all foundation models. + +## Prerequisites + +- **AWS credentials** — any source supported by the [boto3 credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html): + - IAM instance role (EC2, ECS, Lambda — zero config) + - `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` environment variables + - `AWS_PROFILE` for SSO or named profiles + - `aws configure` for local development +- **boto3** — install with `pip install hermes-agent[bedrock]` +- **IAM permissions** — at minimum: + - `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` (for inference) + - `bedrock:ListFoundationModels` and `bedrock:ListInferenceProfiles` (for model discovery) + +:::tip EC2 / ECS / Lambda +On AWS compute, attach an IAM role with `AmazonBedrockFullAccess` and you're done. No API keys, no `.env` configuration — Hermes detects the instance role automatically. +::: + +## Quick Start + +```bash +# Install with Bedrock support +pip install hermes-agent[bedrock] + +# Select Bedrock as your provider +hermes model +# → Choose "More providers..." → "AWS Bedrock" +# → Select your region and model + +# Start chatting +hermes chat +``` + +## Configuration + +After running `hermes model`, your `~/.hermes/config.yaml` will contain: + +```yaml +model: + default: us.anthropic.claude-sonnet-4-6 + provider: bedrock + base_url: https://bedrock-runtime.us-east-2.amazonaws.com + +bedrock: + region: us-east-2 +``` + +### Region + +Set the AWS region in any of these ways (highest priority first): + +1. `bedrock.region` in `config.yaml` +2. `AWS_REGION` environment variable +3. `AWS_DEFAULT_REGION` environment variable +4. Default: `us-east-1` + +### Guardrails + +To apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html) to all model invocations: + +```yaml +bedrock: + region: us-east-2 + guardrail: + guardrail_identifier: "abc123def456" # From the Bedrock console + guardrail_version: "1" # Version number or "DRAFT" + stream_processing_mode: "async" # "sync" or "async" + trace: "disabled" # "enabled", "disabled", or "enabled_full" +``` + +### Model Discovery + +Hermes auto-discovers available models via the Bedrock control plane. You can customize discovery: + +```yaml +bedrock: + discovery: + enabled: true + provider_filter: ["anthropic", "amazon"] # Only show these providers + refresh_interval: 3600 # Cache for 1 hour +``` + +## Available Models + +Bedrock models use **inference profile IDs** for on-demand invocation. The `hermes model` picker shows these automatically, with recommended models at the top: + +| Model | ID | Notes | +|-------|-----|-------| +| Claude Sonnet 4.6 | `us.anthropic.claude-sonnet-4-6` | Recommended — best balance of speed and capability | +| Claude Opus 4.6 | `us.anthropic.claude-opus-4-6-v1` | Most capable | +| Claude Haiku 4.5 | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Fastest Claude | +| Amazon Nova Pro | `us.amazon.nova-pro-v1:0` | Amazon's flagship | +| Amazon Nova Micro | `us.amazon.nova-micro-v1:0` | Fastest, cheapest | +| DeepSeek V3.2 | `deepseek.v3.2` | Strong open model | +| Llama 4 Scout 17B | `us.meta.llama4-scout-17b-instruct-v1:0` | Meta's latest | + +:::info Cross-Region Inference +Models prefixed with `us.` use cross-region inference profiles, which provide better capacity and automatic failover across AWS regions. Models prefixed with `global.` route across all available regions worldwide. +::: + +## Switching Models Mid-Session + +Use the `/model` command during a conversation: + +``` +/model us.amazon.nova-pro-v1:0 +/model deepseek.v3.2 +/model us.anthropic.claude-opus-4-6-v1 +``` + +## Diagnostics + +```bash +hermes doctor +``` + +The doctor checks: +- Whether AWS credentials are available (env vars, IAM role, SSO) +- Whether `boto3` is installed +- Whether the Bedrock API is reachable (ListFoundationModels) +- Number of available models in your region + +## Gateway (Messaging Platforms) + +Bedrock works with all Hermes gateway platforms (Telegram, Discord, Slack, Feishu, etc.). Configure Bedrock as your provider, then start the gateway normally: + +```bash +hermes gateway setup +hermes gateway start +``` + +The gateway reads `config.yaml` and uses the same Bedrock provider configuration. + +## Troubleshooting + +### "No API key found" / "No AWS credentials" + +Hermes checks for credentials in this order: +1. `AWS_BEARER_TOKEN_BEDROCK` +2. `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` +3. `AWS_PROFILE` +4. EC2 instance metadata (IMDS) +5. ECS container credentials +6. Lambda execution role + +If none are found, run `aws configure` or attach an IAM role to your compute instance. + +### "Invocation of model ID ... with on-demand throughput isn't supported" + +Use an **inference profile ID** (prefixed with `us.` or `global.`) instead of the bare foundation model ID. For example: +- ❌ `anthropic.claude-sonnet-4-6` +- ✅ `us.anthropic.claude-sonnet-4-6` + +### "ThrottlingException" + +You've hit the Bedrock per-model rate limit. Hermes automatically retries with backoff. To increase limits, request a quota increase in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/). From 2918328009ca6ce556fdc3d7fcf8c0cc9a0a3972 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:31:08 -0700 Subject: [PATCH 232/849] fix: show correct env var name in provider API key error (#9506) (#10563) The error message for missing provider API keys dynamically built the env var name as PROVIDER_API_KEY (e.g. ALIBABA_API_KEY), but some providers use different names (alibaba uses DASHSCOPE_API_KEY). Users following the error message set the wrong variable. Fix: look up the actual env var from PROVIDER_REGISTRY before building the error. Falls back to the dynamic name if the registry lookup fails. Closes #9506 --- run_agent.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 6033da341..6cbc6f6ee 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1003,9 +1003,20 @@ class AIAgent: # message instead of silently routing through OpenRouter. _explicit = (self.provider or "").strip().lower() if _explicit and _explicit not in ("auto", "openrouter", "custom"): + # Look up the actual env var name from the provider + # config — some providers use non-standard names + # (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY). + _env_hint = f"{_explicit.upper()}_API_KEY" + try: + from hermes_cli.auth import PROVIDER_REGISTRY + _pcfg = PROVIDER_REGISTRY.get(_explicit) + if _pcfg and _pcfg.api_key_env_vars: + _env_hint = _pcfg.api_key_env_vars[0] + except Exception: + pass raise RuntimeError( f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_explicit.upper()}_API_KEY environment " + f"was found. Set the {_env_hint} environment " f"variable, or switch to a different provider with `hermes model`." ) # Final fallback: try raw OpenRouter key From 2fbdc2c8faa263360961c8c05473be80e300555e Mon Sep 17 00:00:00 2001 From: Brenner Spear Date: Mon, 13 Apr 2026 15:57:03 -0700 Subject: [PATCH 233/849] feat(discord): add channel_prompts config Add native Discord channel_prompts support with parent forum fallback, ephemeral runtime injection, config migration updates, docs, and tests. --- gateway/config.py | 6 + gateway/platforms/base.py | 4 + gateway/platforms/discord.py | 38 ++- gateway/run.py | 11 +- hermes_cli/config.py | 3 +- tests/gateway/test_config.py | 21 ++ tests/gateway/test_discord_channel_prompts.py | 229 ++++++++++++++++++ tests/hermes_cli/test_config.py | 24 +- tests/tools/test_browser_camofox_state.py | 2 +- website/docs/user-guide/messaging/discord.md | 23 ++ 10 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_discord_channel_prompts.py diff --git a/gateway/config.py b/gateway/config.py index 7ce105f33..b558ea59f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -554,6 +554,12 @@ def load_gateway_config() -> GatewayConfig: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] + if plat == Platform.DISCORD and "channel_prompts" in platform_cfg: + channel_prompts = platform_cfg["channel_prompts"] + if isinstance(channel_prompts, dict): + bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} + else: + bridged["channel_prompts"] = channel_prompts if not bridged: continue plat_data = platforms_data.setdefault(plat.value, {}) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1561cd526..2d3e54698 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -682,6 +682,10 @@ class MessageEvent: # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, # Discord channel_skill_bindings). A single name or ordered list. auto_skill: Optional[str | list[str]] = None + + # Per-channel ephemeral system prompt (e.g. Discord channel_prompts). + # Applied at API call time and never persisted to transcript history. + channel_prompt: Optional[str] = None # Internal flag — set for synthetic events (e.g. background process # completion notifications) that must bypass user authorization checks. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 091b15f61..da56a61af 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1992,11 +1992,14 @@ class DiscordAdapter(BasePlatformAdapter): ) msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + channel_id = str(interaction.channel_id) + parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") return MessageEvent( text=text, message_type=msg_type, source=source, raw_message=interaction, + channel_prompt=self._resolve_channel_prompt(channel_id, parent_id or None), ) # ------------------------------------------------------------------ @@ -2067,14 +2070,17 @@ class DiscordAdapter(BasePlatformAdapter): chat_topic=chat_topic, ) - _parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") + _parent_channel = self._thread_parent_channel(getattr(interaction, "channel", None)) + _parent_id = str(getattr(_parent_channel, "id", "") or "") _skills = self._resolve_channel_skills(thread_id, _parent_id or None) + _channel_prompt = self._resolve_channel_prompt(thread_id, _parent_id or None) event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, raw_message=interaction, auto_skill=_skills, + channel_prompt=_channel_prompt, ) await self.handle_message(event) @@ -2103,6 +2109,34 @@ class DiscordAdapter(BasePlatformAdapter): return list(dict.fromkeys(skills)) # dedup, preserve order return None + def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None: + """Resolve a Discord per-channel prompt, preferring the exact channel over its parent. + + Config format (in platform extra): + channel_prompts: + "123456": "Prompt text" + + Forum/thread messages inherit the parent forum/channel prompt when the + thread itself has no explicit override. + """ + prompts = self.config.extra.get("channel_prompts") or {} + if not isinstance(prompts, dict): + return None + prompts = {str(k): v for k, v in prompts.items()} + + for key in (channel_id, parent_id): + if not key: + continue + prompt = prompts.get(key) + if prompt is None: + prompt = prompts.get(str(key)) + if prompt is None: + continue + prompt = str(prompt).strip() + if prompt: + return prompt + return None + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -2654,6 +2688,7 @@ class DiscordAdapter(BasePlatformAdapter): _parent_id = str(getattr(_chan, "parent_id", "") or "") _chan_id = str(getattr(_chan, "id", "")) _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) + _channel_prompt = self._resolve_channel_prompt(_chan_id, _parent_id or None) reply_to_id = None reply_to_text = None @@ -2674,6 +2709,7 @@ class DiscordAdapter(BasePlatformAdapter): reply_to_text=reply_to_text, timestamp=message.created_at, auto_skill=_skills, + channel_prompt=_channel_prompt, ) # Track thread participation so the bot won't require @mention for diff --git a/gateway/run.py b/gateway/run.py index 80797358d..1c74b03ca 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2891,6 +2891,7 @@ class GatewayRunner: message_type=_MT.TEXT, source=event.source, message_id=event.message_id, + channel_prompt=getattr(event, "channel_prompt", None), ) adapter._pending_messages[_quick_key] = queued_event return "Queued for the next turn." @@ -3868,6 +3869,7 @@ class GatewayRunner: session_id=session_entry.session_id, session_key=session_key, event_message_id=event.message_id, + channel_prompt=getattr(event, "channel_prompt", None), ) # Stop persistent typing indicator now that the agent is done @@ -5089,6 +5091,7 @@ class GatewayRunner: message_type=MessageType.TEXT, source=source, raw_message=event.raw_message, + channel_prompt=getattr(event, "channel_prompt", None), ) # Let the normal message handler process it @@ -8069,6 +8072,7 @@ class GatewayRunner: session_key: str = None, _interrupt_depth: int = 0, event_message_id: Optional[str] = None, + channel_prompt: Optional[str] = None, ) -> Dict[str, Any]: """ Run the agent with the given message and context. @@ -8423,8 +8427,12 @@ class GatewayRunner: # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value - # Combine platform context with user-configured ephemeral system prompt + # Combine platform context, per-channel context, and the user-configured + # ephemeral system prompt. combined_ephemeral = context_prompt or "" + event_channel_prompt = (channel_prompt or "").strip() + if event_channel_prompt: + combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() if self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() @@ -9376,6 +9384,7 @@ class GatewayRunner: session_key=session_key, _interrupt_depth=_interrupt_depth + 1, event_message_id=next_message_id, + channel_prompt=getattr(pending_event, "channel_prompt", None), ) finally: # Stop progress sender, interrupt monitor, and notification task diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 71f025adf..99c7f003f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -659,6 +659,7 @@ DEFAULT_CONFIG = { "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing + "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) }, # WhatsApp platform settings (gateway mode) @@ -724,7 +725,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 17, + "_config_version": 18, } # ============================================================================= diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c08e263dd..7b64331b9 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -193,6 +193,27 @@ class TestLoadGatewayConfig: assert config.thread_sessions_per_user is False + def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " channel_prompts:\n" + " \"123\": Research mode\n" + " 456: Therapist mode\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.DISCORD].extra["channel_prompts"] == { + "123": "Research mode", + "456": "Therapist mode", + } + def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_discord_channel_prompts.py b/tests/gateway/test_discord_channel_prompts.py new file mode 100644 index 000000000..633fa17bc --- /dev/null +++ b/tests/gateway/test_discord_channel_prompts.py @@ -0,0 +1,229 @@ +"""Tests for Discord channel_prompts resolution and injection.""" + +import sys +import threading +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +class _CapturingAgent: + last_init = None + + def __init__(self, *args, **kwargs): + type(self).last_init = dict(kwargs) + self.tools = [] + + def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None): + return { + "final_response": "ok", + "messages": [], + "api_calls": 1, + "completed": True, + } + + +def _install_fake_agent(monkeypatch): + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = _CapturingAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + +def _make_adapter(): + from gateway.platforms.discord import DiscordAdapter + + adapter = object.__new__(DiscordAdapter) + adapter.config = MagicMock() + adapter.config.extra = {} + return adapter + + +def _make_runner(): + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {} + runner._ephemeral_system_prompt = "Global prompt" + runner._prefill_messages = [] + runner._reasoning_config = None + runner._service_tier = None + runner._provider_routing = {} + runner._fallback_model = None + runner._smart_model_routing = {} + runner._running_agents = {} + runner._pending_model_notes = {} + runner._session_db = None + runner._agent_cache = {} + runner._agent_cache_lock = threading.Lock() + runner._session_model_overrides = {} + runner.hooks = SimpleNamespace(loaded_hooks=False) + runner.config = SimpleNamespace(streaming=None) + runner.session_store = SimpleNamespace( + get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"), + load_transcript=lambda session_id: [], + ) + runner._get_or_create_gateway_honcho = lambda session_key: (None, None) + runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED") + return runner + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + chat_id="12345", + chat_type="thread", + user_id="user-1", + ) + + +class TestResolveChannelPrompts: + def test_no_prompt_returns_none(self): + adapter = _make_adapter() + assert adapter._resolve_channel_prompt("123") is None + + def test_match_by_channel_id(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"100": "Research mode"}} + assert adapter._resolve_channel_prompt("100") == "Research mode" + + def test_match_by_numeric_channel_id_key(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {100: "Research mode"}} + assert adapter._resolve_channel_prompt("100") == "Research mode" + + def test_match_by_parent_id(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"200": "Forum prompt"}} + assert adapter._resolve_channel_prompt("999", parent_id="200") == "Forum prompt" + + def test_exact_channel_overrides_parent(self): + adapter = _make_adapter() + adapter.config.extra = { + "channel_prompts": { + "999": "Thread override", + "200": "Forum prompt", + } + } + assert adapter._resolve_channel_prompt("999", parent_id="200") == "Thread override" + + def test_build_message_event_sets_channel_prompt(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"321": "Command prompt"}} + adapter.build_source = MagicMock(return_value=SimpleNamespace()) + + interaction = SimpleNamespace( + channel_id=321, + channel=SimpleNamespace(name="general", guild=None, parent_id=None), + user=SimpleNamespace(id=1, display_name="Brenner"), + ) + adapter._get_effective_topic = MagicMock(return_value=None) + + event = adapter._build_slash_event(interaction, "/retry") + + assert event.channel_prompt == "Command prompt" + + @pytest.mark.asyncio + async def test_dispatch_thread_session_inherits_parent_channel_prompt(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"200": "Parent prompt"}} + adapter.build_source = MagicMock(return_value=SimpleNamespace()) + adapter._get_effective_topic = MagicMock(return_value=None) + adapter.handle_message = AsyncMock() + + interaction = SimpleNamespace( + guild=SimpleNamespace(name="Wetlands"), + channel=SimpleNamespace(id=200, parent=None), + user=SimpleNamespace(id=1, display_name="Brenner"), + ) + + await adapter._dispatch_thread_session(interaction, "999", "new-thread", "hello") + + dispatched_event = adapter.handle_message.await_args.args[0] + assert dispatched_event.channel_prompt == "Parent prompt" + + def test_blank_prompts_are_ignored(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"100": " "}} + assert adapter._resolve_channel_prompt("100") is None + + +@pytest.mark.asyncio +async def test_retry_preserves_channel_prompt(monkeypatch): + runner = _make_runner() + runner.session_store = SimpleNamespace( + get_or_create_session=lambda source: SimpleNamespace(session_id="session-1", last_prompt_tokens=10), + load_transcript=lambda session_id: [ + {"role": "user", "content": "original message"}, + {"role": "assistant", "content": "old reply"}, + ], + rewrite_transcript=MagicMock(), + ) + runner._handle_message = AsyncMock(return_value="ok") + + event = MessageEvent( + text="/retry", + message_type=gateway_run.MessageType.COMMAND, + source=_make_source(), + raw_message=SimpleNamespace(), + channel_prompt="Channel prompt", + ) + + result = await runner._handle_retry_command(event) + + assert result == "ok" + retried_event = runner._handle_message.await_args.args[0] + assert retried_event.channel_prompt == "Channel prompt" + + +@pytest.mark.asyncio +async def test_run_agent_appends_channel_prompt_to_ephemeral_system_prompt(monkeypatch, tmp_path): + _install_fake_agent(monkeypatch) + runner = _make_runner() + + (tmp_path / "config.yaml").write_text("agent:\n system_prompt: Global prompt\n", encoding="utf-8") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env") + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + monkeypatch.setattr( + gateway_run, + "_resolve_runtime_agent_kwargs", + lambda: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "***", + }, + ) + + import hermes_cli.tools_config as tools_config + + monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"}) + + _CapturingAgent.last_init = None + event = MessageEvent( + text="hi", + source=_make_source(), + message_id="m1", + channel_prompt="Channel prompt", + ) + result = await runner._run_agent( + message="hi", + context_prompt="Context prompt", + history=[], + source=_make_source(), + session_id="session-1", + session_key="agent:main:discord:thread:12345", + channel_prompt=event.channel_prompt, + ) + + assert result["final_response"] == "ok" + assert _CapturingAgent.last_init["ephemeral_system_prompt"] == ( + "Context prompt\n\nChannel prompt\n\nGlobal prompt" + ) diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 9f77bb4c8..f31ac045c 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -459,7 +459,7 @@ class TestCustomProviderCompatibility: migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert raw["_config_version"] == 17 + assert raw["_config_version"] == 18 assert raw["providers"]["openai-direct"] == { "api": "https://api.openai.com/v1", "api_key": "test-key", @@ -606,6 +606,26 @@ class TestInterimAssistantMessageConfig: migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert raw["_config_version"] == 17 + assert raw["_config_version"] == 18 assert raw["display"]["tool_progress"] == "off" assert raw["display"]["interim_assistant_messages"] is True + + +class TestDiscordChannelPromptsConfig: + def test_default_config_includes_discord_channel_prompts(self): + assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {} + + def test_migrate_adds_discord_channel_prompts_default(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + migrate_config(interactive=False, quiet=True) + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + assert raw["_config_version"] == 18 + assert raw["discord"]["auto_thread"] is True + assert raw["discord"]["channel_prompts"] == {} diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 475e8c2d0..05f679efe 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -64,4 +64,4 @@ class TestCamofoxConfigDefaults: # The current schema version is tracked globally; unrelated default # options may bump it after browser defaults are added. - assert DEFAULT_CONFIG["_config_version"] == 17 + assert DEFAULT_CONFIG["_config_version"] == 18 diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 111bea596..5dacefda4 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -297,6 +297,7 @@ discord: reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading + channel_prompts: {} # Per-channel ephemeral system prompts # Session isolation (applies to all gateway platforms, not just Discord) group_sessions_per_user: true # Isolate sessions per user in shared channels @@ -381,6 +382,28 @@ discord: Useful for channels dedicated to bot interaction where threads would add unnecessary noise. +#### `discord.channel_prompts` + +**Type:** mapping — **Default:** `{}` + +Per-channel ephemeral system prompts that are injected on every turn in the matching Discord channel or thread without being persisted to transcript history. + +```yaml +discord: + channel_prompts: + "1234567890": | + This channel is for research tasks. Prefer deep comparisons, + citations, and concise synthesis. + "9876543210": | + This forum is for therapy-style support. Be warm, grounded, + and non-judgmental. +``` + +Behavior: +- Exact thread/channel ID matches win. +- If a message arrives inside a thread or forum post and that thread has no explicit entry, Hermes falls back to the parent channel/forum ID. +- Prompts are applied ephemerally at runtime, so changing them affects future turns immediately without rewriting past session history. + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true` From 90a6336145cc48bcff945b159b7a9719dc933ef1 Mon Sep 17 00:00:00 2001 From: Brenner Spear Date: Mon, 13 Apr 2026 17:26:25 -0700 Subject: [PATCH 234/849] fix: remove redundant key normalization and defensive getattr in channel_prompts - Remove double str() normalization in _resolve_channel_prompt since config bridging already handles numeric YAML key conversion - Remove dead prompts.get(str(key)) fallback that could never match after keys were already normalized to strings - Replace getattr(event, "channel_prompt", None) with direct attribute access since channel_prompt is a declared dataclass field - Update test to verify normalization responsibility lives in config bridging --- gateway/platforms/discord.py | 3 --- gateway/run.py | 8 ++++---- tests/gateway/test_discord_channel_prompts.py | 13 +++++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index da56a61af..bf48fc7d1 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2122,14 +2122,11 @@ class DiscordAdapter(BasePlatformAdapter): prompts = self.config.extra.get("channel_prompts") or {} if not isinstance(prompts, dict): return None - prompts = {str(k): v for k, v in prompts.items()} for key in (channel_id, parent_id): if not key: continue prompt = prompts.get(key) - if prompt is None: - prompt = prompts.get(str(key)) if prompt is None: continue prompt = str(prompt).strip() diff --git a/gateway/run.py b/gateway/run.py index 1c74b03ca..a95ca159b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2891,7 +2891,7 @@ class GatewayRunner: message_type=_MT.TEXT, source=event.source, message_id=event.message_id, - channel_prompt=getattr(event, "channel_prompt", None), + channel_prompt=event.channel_prompt, ) adapter._pending_messages[_quick_key] = queued_event return "Queued for the next turn." @@ -3869,7 +3869,7 @@ class GatewayRunner: session_id=session_entry.session_id, session_key=session_key, event_message_id=event.message_id, - channel_prompt=getattr(event, "channel_prompt", None), + channel_prompt=event.channel_prompt, ) # Stop persistent typing indicator now that the agent is done @@ -5091,7 +5091,7 @@ class GatewayRunner: message_type=MessageType.TEXT, source=source, raw_message=event.raw_message, - channel_prompt=getattr(event, "channel_prompt", None), + channel_prompt=event.channel_prompt, ) # Let the normal message handler process it @@ -9384,7 +9384,7 @@ class GatewayRunner: session_key=session_key, _interrupt_depth=_interrupt_depth + 1, event_message_id=next_message_id, - channel_prompt=getattr(pending_event, "channel_prompt", None), + channel_prompt=pending_event.channel_prompt, ) finally: # Stop progress sender, interrupt monitor, and notification task diff --git a/tests/gateway/test_discord_channel_prompts.py b/tests/gateway/test_discord_channel_prompts.py index 633fa17bc..d29180cbd 100644 --- a/tests/gateway/test_discord_channel_prompts.py +++ b/tests/gateway/test_discord_channel_prompts.py @@ -91,10 +91,19 @@ class TestResolveChannelPrompts: adapter.config.extra = {"channel_prompts": {"100": "Research mode"}} assert adapter._resolve_channel_prompt("100") == "Research mode" - def test_match_by_numeric_channel_id_key(self): + def test_numeric_yaml_keys_normalized_at_config_load(self): + """Numeric YAML keys are normalized to strings by config bridging. + + The resolver itself expects string keys (config.py handles normalization), + so raw numeric keys will not match — this is intentional. + """ adapter = _make_adapter() - adapter.config.extra = {"channel_prompts": {100: "Research mode"}} + # Simulates post-bridging state: keys are already strings + adapter.config.extra = {"channel_prompts": {"100": "Research mode"}} assert adapter._resolve_channel_prompt("100") == "Research mode" + # Pre-bridging numeric key would not match (bridging is responsible) + adapter.config.extra = {"channel_prompts": {100: "Research mode"}} + assert adapter._resolve_channel_prompt("100") is None def test_match_by_parent_id(self): adapter = _make_adapter() From 620c296b1de23ff574ce3ebb01163657dafe27b5 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 16:12:31 -0700 Subject: [PATCH 235/849] fix: discord mock setup and AUTHOR_MAP for channel_prompts tests Move _ensure_discord_mock() from module level to _make_adapter() so it doesn't poison sys.modules for other discord test files. Use types.ModuleType instead of MagicMock for the mock module to avoid auto-generated __file__ attribute confusing hasattr checks. Add BrennerSpear to AUTHOR_MAP. --- scripts/release.py | 1 + tests/gateway/test_discord_channel_prompts.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 4e5b19322..0c021633b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -82,6 +82,7 @@ AUTHOR_MAP = { "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "4317663+helix4u@users.noreply.github.com": "helix4u", "331214+counterposition@users.noreply.github.com": "counterposition", + "blspear@gmail.com": "BrennerSpear", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", diff --git a/tests/gateway/test_discord_channel_prompts.py b/tests/gateway/test_discord_channel_prompts.py index d29180cbd..9c475bded 100644 --- a/tests/gateway/test_discord_channel_prompts.py +++ b/tests/gateway/test_discord_channel_prompts.py @@ -8,6 +8,26 @@ from unittest.mock import AsyncMock, MagicMock import pytest + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + discord_mod = types.ModuleType("discord") + discord_mod.Intents = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + import gateway.run as gateway_run from gateway.config import Platform from gateway.platforms.base import MessageEvent @@ -37,6 +57,7 @@ def _install_fake_agent(monkeypatch): def _make_adapter(): + _ensure_discord_mock() from gateway.platforms.discord import DiscordAdapter adapter = object.__new__(DiscordAdapter) From 0d05bd34f831c7c0e2635a5001344d413e039c50 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 16:26:26 -0700 Subject: [PATCH 236/849] feat: extend channel_prompts to Telegram, Slack, and Mattermost Extract resolve_channel_prompt() shared helper into gateway/platforms/base.py. Refactor Discord to use it. Wire channel_prompts into Telegram (groups + forum topics), Slack (channels), and Mattermost (channels). Config bridging now applies to all platforms (not just Discord). Added channel_prompts defaults to telegram/slack/mattermost config sections. Docs added to all four platform pages with platform-specific examples (topic inheritance for Telegram, channel IDs for Slack, etc.). --- gateway/config.py | 2 +- gateway/platforms/base.py | 30 ++++++++++++++ gateway/platforms/discord.py | 26 ++---------- gateway/platforms/mattermost.py | 7 ++++ gateway/platforms/slack.py | 7 ++++ gateway/platforms/telegram.py | 10 +++++ hermes_cli/config.py | 15 +++++++ tests/gateway/test_config.py | 40 +++++++++++++++++++ .../docs/user-guide/messaging/mattermost.md | 17 ++++++++ website/docs/user-guide/messaging/slack.md | 17 ++++++++ website/docs/user-guide/messaging/telegram.md | 23 +++++++++++ 11 files changed, 170 insertions(+), 24 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index b558ea59f..72fde982a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -554,7 +554,7 @@ def load_gateway_config() -> GatewayConfig: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] - if plat == Platform.DISCORD and "channel_prompts" in platform_cfg: + if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] if isinstance(channel_prompts, dict): bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2d3e54698..c718cce89 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -780,6 +780,36 @@ _RETRYABLE_ERROR_PATTERNS = ( MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]] +def resolve_channel_prompt( + config_extra: dict, + channel_id: str, + parent_id: str | None = None, +) -> str | None: + """Resolve a per-channel ephemeral prompt from platform config. + + Looks up ``channel_prompts`` in the adapter's ``config.extra`` dict. + Prefers an exact match on *channel_id*; falls back to *parent_id* + (useful for forum threads / child channels inheriting a parent prompt). + + Returns the prompt string, or None if no match is found. Blank/whitespace- + only prompts are treated as absent. + """ + prompts = config_extra.get("channel_prompts") or {} + if not isinstance(prompts, dict): + return None + + for key in (channel_id, parent_id): + if not key: + continue + prompt = prompts.get(key) + if prompt is None: + continue + prompt = str(prompt).strip() + if prompt: + return prompt + return None + + class BasePlatformAdapter(ABC): """ Base class for platform adapters. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index bf48fc7d1..37890f99f 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2110,29 +2110,9 @@ class DiscordAdapter(BasePlatformAdapter): return None def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None: - """Resolve a Discord per-channel prompt, preferring the exact channel over its parent. - - Config format (in platform extra): - channel_prompts: - "123456": "Prompt text" - - Forum/thread messages inherit the parent forum/channel prompt when the - thread itself has no explicit override. - """ - prompts = self.config.extra.get("channel_prompts") or {} - if not isinstance(prompts, dict): - return None - - for key in (channel_id, parent_id): - if not key: - continue - prompt = prompts.get(key) - if prompt is None: - continue - prompt = str(prompt).strip() - if prompt: - return prompt - return None + """Resolve a Discord per-channel prompt, preferring the exact channel over its parent.""" + from gateway.platforms.base import resolve_channel_prompt + return resolve_channel_prompt(self.config.extra, channel_id, parent_id) def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 23a86f02b..18367a8e4 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -718,6 +718,12 @@ class MattermostAdapter(BasePlatformAdapter): thread_id=thread_id, ) + # Per-channel ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _channel_prompt = resolve_channel_prompt( + self.config.extra, channel_id, None, + ) + msg_event = MessageEvent( text=message_text, message_type=msg_type, @@ -726,6 +732,7 @@ class MattermostAdapter(BasePlatformAdapter): message_id=post_id, media_urls=media_urls if media_urls else None, media_types=media_types if media_types else None, + channel_prompt=_channel_prompt, ) await self.handle_message(msg_event) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 8f9934cf7..3421d7cf7 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1167,6 +1167,12 @@ class SlackAdapter(BasePlatformAdapter): thread_id=thread_ts, ) + # Per-channel ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _channel_prompt = resolve_channel_prompt( + self.config.extra, channel_id, None, + ) + msg_event = MessageEvent( text=text, message_type=msg_type, @@ -1176,6 +1182,7 @@ class SlackAdapter(BasePlatformAdapter): media_urls=media_urls, media_types=media_types, reply_to_message_id=thread_ts if thread_ts != ts else None, + channel_prompt=_channel_prompt, ) # Only react when bot is directly addressed (DM or @mention). diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0806362b3..09af14f34 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2775,6 +2775,15 @@ class TelegramAdapter(BasePlatformAdapter): reply_to_id = str(message.reply_to_message.message_id) reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None + # Per-channel/topic ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _chat_id_str = str(chat.id) + _channel_prompt = resolve_channel_prompt( + self.config.extra, + thread_id_str or _chat_id_str, + _chat_id_str if thread_id_str else None, + ) + return MessageEvent( text=message.text or "", message_type=msg_type, @@ -2784,6 +2793,7 @@ class TelegramAdapter(BasePlatformAdapter): reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, auto_skill=topic_skill, + channel_prompt=_channel_prompt, timestamp=message.date, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 99c7f003f..4794e74c7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -670,6 +670,21 @@ DEFAULT_CONFIG = { # Supports \n for newlines, e.g. "🤖 *My Bot*\n──────\n" }, + # Telegram platform settings (gateway mode) + "telegram": { + "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) + }, + + # Slack platform settings (gateway mode) + "slack": { + "channel_prompts": {}, # Per-channel ephemeral system prompts + }, + + # Mattermost platform settings (gateway mode) + "mattermost": { + "channel_prompts": {}, # Per-channel ephemeral system prompts + }, + # Approval mode for dangerous commands: # manual — always prompt the user (default) # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 7b64331b9..1496c6766 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -214,6 +214,46 @@ class TestLoadGatewayConfig: "456": "Therapist mode", } + def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " channel_prompts:\n" + ' "-1001234567": Research assistant\n' + " 789: Creative writing\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.TELEGRAM].extra["channel_prompts"] == { + "-1001234567": "Research assistant", + "789": "Creative writing", + } + + def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "slack:\n" + " channel_prompts:\n" + ' "C01ABC": Code review mode\n', + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.SLACK].extra["channel_prompts"] == { + "C01ABC": "Code review mode", + } + def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/website/docs/user-guide/messaging/mattermost.md b/website/docs/user-guide/messaging/mattermost.md index cff50e94d..6d4540154 100644 --- a/website/docs/user-guide/messaging/mattermost.md +++ b/website/docs/user-guide/messaging/mattermost.md @@ -281,6 +281,23 @@ If this returns your bot's user info, the token is valid. If it returns an error **Fix**: Add your User ID to `MATTERMOST_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. Remember: the User ID is a 26-character alphanumeric string, not your `@username`. +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Mattermost channels. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +mattermost: + channel_prompts: + "channel_id_abc123": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "channel_id_def456": | + Code review mode. Be precise about edge cases and + performance implications. +``` + +Keys are Mattermost channel IDs (find them in the channel URL or via the API). All messages in the matching channel get the prompt injected as an ephemeral system instruction. + ## Security :::warning diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index b266535a3..5f6492216 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -418,6 +418,23 @@ Hermes supports voice on Slack: --- +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Slack channels. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +slack: + channel_prompts: + "C01RESEARCH": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "C02ENGINEERING": | + Code review mode. Be precise about edge cases and + performance implications. +``` + +Keys are Slack channel IDs (find them via channel details → "About" → scroll to bottom). All messages in the matching channel get the prompt injected as an ephemeral system instruction. + ## Troubleshooting | Problem | Solution | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 4e4495ad2..7fc965bcb 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -526,6 +526,29 @@ Unlike Discord (where reactions are additive), Telegram's Bot API replaces all b If the bot doesn't have permission to add reactions in a group, the reaction calls fail silently and message processing continues normally. ::: +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Telegram groups or forum topics. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +telegram: + channel_prompts: + "-1001234567890": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "42": | + This topic is for creative writing feedback. Be warm and + constructive. +``` + +Keys are chat IDs (groups/supergroups) or forum topic IDs. For forum groups, topic-level prompts override the group-level prompt: + +- Message in topic `42` inside group `-1001234567890` → uses topic `42`'s prompt +- Message in topic `99` (no explicit entry) → falls back to group `-1001234567890`'s prompt +- Message in a group with no entry → no channel prompt applied + +Numeric YAML keys are automatically normalized to strings. + ## Troubleshooting | Problem | Solution | From 9d9b424390c429d92f6bb7261d498d3417132952 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:31:48 -0700 Subject: [PATCH 237/849] =?UTF-8?q?fix:=20Nous=20Portal=20rate=20limit=20g?= =?UTF-8?q?uard=20=E2=80=94=20prevent=20retry=20amplification=20(#10568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Nous returns a 429, the retry amplification chain burns up to 9 API requests per conversation turn (3 SDK retries × 3 Hermes retries), each counting against RPH and deepening the rate limit. With multiple concurrent sessions (cron + gateway + auxiliary), this creates a spiral where retries keep the limit tapped indefinitely. New module: agent/nous_rate_guard.py - Shared file-based rate limit state (~/.hermes/rate_limits/nous.json) - Parses reset time from x-ratelimit-reset-requests-1h, x-ratelimit- reset-requests, retry-after headers, or error context - Falls back to 5-minute default cooldown if no header data - Atomic writes (tempfile + rename) for cross-process safety - Auto-cleanup of expired state files run_agent.py changes: - Top-of-retry-loop guard: when another session already recorded Nous as rate-limited, skip the API call entirely. Try fallback provider first, then return a clear message with the reset time. - On 429 from Nous: record rate limit state and skip further retries (sets retry_count = max_retries to trigger fallback path) - On success from Nous: clear the rate limit state so other sessions know they can resume auxiliary_client.py changes: - _try_nous() checks rate guard before attempting Nous in the auxiliary fallback chain. When rate-limited, returns (None, None) so the chain skips to the next provider instead of piling more requests onto Nous. This eliminates three sources of amplification: 1. Hermes-level retries (saves 6 of 9 calls per turn) 2. Cross-session retries (cron + gateway all skip Nous) 3. Auxiliary fallback to Nous (compression/session_search skip too) Includes 24 tests covering the rate guard module, header parsing, state lifecycle, and auxiliary client integration. --- agent/auxiliary_client.py | 15 ++ agent/nous_rate_guard.py | 182 ++++++++++++++++++++ run_agent.py | 88 ++++++++++ tests/agent/test_nous_rate_guard.py | 253 ++++++++++++++++++++++++++++ 4 files changed, 538 insertions(+) create mode 100644 agent/nous_rate_guard.py create mode 100644 tests/agent/test_nous_rate_guard.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index fd2f2d812..9702da941 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -775,6 +775,21 @@ def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]: def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: + # Check cross-session rate limit guard before attempting Nous — + # if another session already recorded a 429, skip Nous entirely + # to avoid piling more requests onto the tapped RPH bucket. + try: + from agent.nous_rate_guard import nous_rate_limit_remaining + _remaining = nous_rate_limit_remaining() + if _remaining is not None and _remaining > 0: + logger.debug( + "Auxiliary: skipping Nous Portal (rate-limited, resets in %.0fs)", + _remaining, + ) + return None, None + except Exception: + pass + nous = _read_nous_auth() if not nous: return None, None diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py new file mode 100644 index 000000000..712d8a0f1 --- /dev/null +++ b/agent/nous_rate_guard.py @@ -0,0 +1,182 @@ +"""Cross-session rate limit guard for Nous Portal. + +Writes rate limit state to a shared file so all sessions (CLI, gateway, +cron, auxiliary) can check whether Nous Portal is currently rate-limited +before making requests. Prevents retry amplification when RPH is tapped. + +Each 429 from Nous triggers up to 9 API calls per conversation turn +(3 SDK retries x 3 Hermes retries), and every one of those calls counts +against RPH. By recording the rate limit state on first 429 and checking +it before subsequent attempts, we eliminate the amplification effect. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +import time +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + +_STATE_SUBDIR = "rate_limits" +_STATE_FILENAME = "nous.json" + + +def _state_path() -> str: + """Return the path to the Nous rate limit state file.""" + try: + from hermes_constants import get_hermes_home + base = get_hermes_home() + except ImportError: + base = os.path.join(os.path.expanduser("~"), ".hermes") + return os.path.join(base, _STATE_SUBDIR, _STATE_FILENAME) + + +def _parse_reset_seconds(headers: Optional[Mapping[str, str]]) -> Optional[float]: + """Extract the best available reset-time estimate from response headers. + + Priority: + 1. x-ratelimit-reset-requests-1h (hourly RPH window — most useful) + 2. x-ratelimit-reset-requests (per-minute RPM window) + 3. retry-after (generic HTTP header) + + Returns seconds-from-now, or None if no usable header found. + """ + if not headers: + return None + + lowered = {k.lower(): v for k, v in headers.items()} + + for key in ( + "x-ratelimit-reset-requests-1h", + "x-ratelimit-reset-requests", + "retry-after", + ): + raw = lowered.get(key) + if raw is not None: + try: + val = float(raw) + if val > 0: + return val + except (TypeError, ValueError): + pass + + return None + + +def record_nous_rate_limit( + *, + headers: Optional[Mapping[str, str]] = None, + error_context: Optional[dict[str, Any]] = None, + default_cooldown: float = 300.0, +) -> None: + """Record that Nous Portal is rate-limited. + + Parses the reset time from response headers or error context. + Falls back to ``default_cooldown`` (5 minutes) if no reset info + is available. Writes to a shared file that all sessions can read. + + Args: + headers: HTTP response headers from the 429 error. + error_context: Structured error context from _extract_api_error_context(). + default_cooldown: Fallback cooldown in seconds when no header data. + """ + now = time.time() + reset_at = None + + # Try headers first (most accurate) + header_seconds = _parse_reset_seconds(headers) + if header_seconds is not None: + reset_at = now + header_seconds + + # Try error_context reset_at (from body parsing) + if reset_at is None and isinstance(error_context, dict): + ctx_reset = error_context.get("reset_at") + if isinstance(ctx_reset, (int, float)) and ctx_reset > now: + reset_at = float(ctx_reset) + + # Default cooldown + if reset_at is None: + reset_at = now + default_cooldown + + path = _state_path() + try: + state_dir = os.path.dirname(path) + os.makedirs(state_dir, exist_ok=True) + + state = { + "reset_at": reset_at, + "recorded_at": now, + "reset_seconds": reset_at - now, + } + + # Atomic write: write to temp file + rename + fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(state, f) + os.replace(tmp_path, path) + except Exception: + # Clean up temp file on failure + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + logger.info( + "Nous rate limit recorded: resets in %.0fs (at %.0f)", + reset_at - now, reset_at, + ) + except Exception as exc: + logger.debug("Failed to write Nous rate limit state: %s", exc) + + +def nous_rate_limit_remaining() -> Optional[float]: + """Check if Nous Portal is currently rate-limited. + + Returns: + Seconds remaining until reset, or None if not rate-limited. + """ + path = _state_path() + try: + with open(path) as f: + state = json.load(f) + reset_at = state.get("reset_at", 0) + remaining = reset_at - time.time() + if remaining > 0: + return remaining + # Expired — clean up + try: + os.unlink(path) + except OSError: + pass + return None + except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError): + return None + + +def clear_nous_rate_limit() -> None: + """Clear the rate limit state (e.g., after a successful Nous request).""" + try: + os.unlink(_state_path()) + except FileNotFoundError: + pass + except OSError as exc: + logger.debug("Failed to clear Nous rate limit state: %s", exc) + + +def format_remaining(seconds: float) -> str: + """Format seconds remaining into human-readable duration.""" + s = max(0, int(seconds)) + if s < 60: + return f"{s}s" + if s < 3600: + m, sec = divmod(s, 60) + return f"{m}m {sec}s" if sec else f"{m}m" + h, remainder = divmod(s, 3600) + m = remainder // 60 + return f"{h}h {m}m" if m else f"{h}h" diff --git a/run_agent.py b/run_agent.py index 6cbc6f6ee..f199d806d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8660,6 +8660,53 @@ class AIAgent: api_kwargs = None # Guard against UnboundLocalError in except handler while retry_count < max_retries: + # ── Nous Portal rate limit guard ────────────────────── + # If another session already recorded that Nous is rate- + # limited, skip the API call entirely. Each attempt + # (including SDK-level retries) counts against RPH and + # deepens the rate limit hole. + if self.provider == "nous": + try: + from agent.nous_rate_guard import ( + nous_rate_limit_remaining, + format_remaining as _fmt_nous_remaining, + ) + _nous_remaining = nous_rate_limit_remaining() + if _nous_remaining is not None and _nous_remaining > 0: + _nous_msg = ( + f"Nous Portal rate limit active — " + f"resets in {_fmt_nous_remaining(_nous_remaining)}." + ) + self._vprint( + f"{self.log_prefix}⏳ {_nous_msg} Trying fallback...", + force=True, + ) + self._emit_status(f"⏳ {_nous_msg}") + if self._try_activate_fallback(): + retry_count = 0 + compression_attempts = 0 + primary_recovery_attempted = False + continue + # No fallback available — return with clear message + self._persist_session(messages, conversation_history) + return { + "final_response": ( + f"⏳ {_nous_msg}\n\n" + "No fallback provider available. " + "Try again after the reset, or add a " + "fallback provider in config.yaml." + ), + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": _nous_msg, + } + except ImportError: + pass + except Exception: + pass # Never let rate guard break the agent loop + try: self._reset_stream_delivery_tracking() api_kwargs = self._build_api_kwargs(api_messages) @@ -9248,6 +9295,15 @@ class AIAgent: self._vprint(f"{self.log_prefix} 💾 Cache: {cached:,}/{prompt:,} tokens ({hit_pct:.0f}% hit, {written:,} written)") has_retried_429 = False # Reset on success + # Clear Nous rate limit state on successful request — + # proves the limit has reset and other sessions can + # resume hitting Nous. + if self.provider == "nous": + try: + from agent.nous_rate_guard import clear_nous_rate_limit + clear_nous_rate_limit() + except Exception: + pass self._touch_activity(f"API call #{api_call_count} completed") break # Success, exit retry loop @@ -9659,6 +9715,38 @@ class AIAgent: primary_recovery_attempted = False continue + # ── Nous Portal: record rate limit & skip retries ───── + # When Nous returns a 429, record the reset time to a + # shared file so ALL sessions (cron, gateway, auxiliary) + # know not to pile on. Then skip further retries — + # each one burns another RPH request and deepens the + # rate limit hole. The retry loop's top-of-iteration + # guard will catch this on the next pass and try + # fallback or bail with a clear message. + if ( + is_rate_limited + and self.provider == "nous" + and classified.reason == FailoverReason.rate_limit + and not recovered_with_pool + ): + try: + from agent.nous_rate_guard import record_nous_rate_limit + _err_resp = getattr(api_error, "response", None) + _err_hdrs = ( + getattr(_err_resp, "headers", None) + if _err_resp else None + ) + record_nous_rate_limit( + headers=_err_hdrs, + error_context=error_context, + ) + except Exception: + pass + # Skip straight to max_retries — the top-of-loop + # guard will handle fallback or bail cleanly. + retry_count = max_retries + continue + is_payload_too_large = ( classified.reason == FailoverReason.payload_too_large ) diff --git a/tests/agent/test_nous_rate_guard.py b/tests/agent/test_nous_rate_guard.py new file mode 100644 index 000000000..45d30f724 --- /dev/null +++ b/tests/agent/test_nous_rate_guard.py @@ -0,0 +1,253 @@ +"""Tests for agent/nous_rate_guard.py — cross-session Nous Portal rate limit guard.""" + +import json +import os +import time + +import pytest + + +@pytest.fixture +def rate_guard_env(tmp_path, monkeypatch): + """Isolate rate guard state to a temp directory.""" + hermes_home = str(tmp_path / ".hermes") + os.makedirs(hermes_home, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", hermes_home) + # Clear any cached module-level imports + return hermes_home + + +class TestRecordNousRateLimit: + """Test recording rate limit state.""" + + def test_records_with_header_reset(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + headers = {"x-ratelimit-reset-requests-1h": "1800"} + record_nous_rate_limit(headers=headers) + + path = _state_path() + assert os.path.exists(path) + with open(path) as f: + state = json.load(f) + assert state["reset_seconds"] == pytest.approx(1800, abs=2) + assert state["reset_at"] > time.time() + + def test_records_with_per_minute_header(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + headers = {"x-ratelimit-reset-requests": "45"} + record_nous_rate_limit(headers=headers) + + with open(_state_path()) as f: + state = json.load(f) + assert state["reset_seconds"] == pytest.approx(45, abs=2) + + def test_records_with_retry_after_header(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + headers = {"retry-after": "60"} + record_nous_rate_limit(headers=headers) + + with open(_state_path()) as f: + state = json.load(f) + assert state["reset_seconds"] == pytest.approx(60, abs=2) + + def test_prefers_hourly_over_per_minute(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + headers = { + "x-ratelimit-reset-requests-1h": "1800", + "x-ratelimit-reset-requests": "45", + } + record_nous_rate_limit(headers=headers) + + with open(_state_path()) as f: + state = json.load(f) + # Should use the hourly value, not the per-minute one + assert state["reset_seconds"] == pytest.approx(1800, abs=2) + + def test_falls_back_to_error_context_reset_at(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + future_reset = time.time() + 900 + record_nous_rate_limit( + headers=None, + error_context={"reset_at": future_reset}, + ) + + with open(_state_path()) as f: + state = json.load(f) + assert state["reset_at"] == pytest.approx(future_reset, abs=1) + + def test_falls_back_to_default_cooldown(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + record_nous_rate_limit(headers=None) + + with open(_state_path()) as f: + state = json.load(f) + # Default is 300 seconds (5 minutes) + assert state["reset_seconds"] == pytest.approx(300, abs=2) + + def test_custom_default_cooldown(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + record_nous_rate_limit(headers=None, default_cooldown=120.0) + + with open(_state_path()) as f: + state = json.load(f) + assert state["reset_seconds"] == pytest.approx(120, abs=2) + + def test_creates_directory_if_missing(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, _state_path + + record_nous_rate_limit(headers={"retry-after": "10"}) + assert os.path.exists(_state_path()) + + +class TestNousRateLimitRemaining: + """Test checking remaining rate limit time.""" + + def test_returns_none_when_no_file(self, rate_guard_env): + from agent.nous_rate_guard import nous_rate_limit_remaining + + assert nous_rate_limit_remaining() is None + + def test_returns_remaining_seconds_when_active(self, rate_guard_env): + from agent.nous_rate_guard import record_nous_rate_limit, nous_rate_limit_remaining + + record_nous_rate_limit(headers={"x-ratelimit-reset-requests-1h": "600"}) + remaining = nous_rate_limit_remaining() + assert remaining is not None + assert 595 < remaining <= 605 # ~600 seconds, allowing for test execution time + + def test_returns_none_when_expired(self, rate_guard_env): + from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path + + # Write an already-expired state + state_dir = os.path.dirname(_state_path()) + os.makedirs(state_dir, exist_ok=True) + with open(_state_path(), "w") as f: + json.dump({"reset_at": time.time() - 10, "recorded_at": time.time() - 100}, f) + + assert nous_rate_limit_remaining() is None + # File should be cleaned up + assert not os.path.exists(_state_path()) + + def test_handles_corrupt_file(self, rate_guard_env): + from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path + + state_dir = os.path.dirname(_state_path()) + os.makedirs(state_dir, exist_ok=True) + with open(_state_path(), "w") as f: + f.write("not valid json{{{") + + assert nous_rate_limit_remaining() is None + + +class TestClearNousRateLimit: + """Test clearing rate limit state.""" + + def test_clears_existing_file(self, rate_guard_env): + from agent.nous_rate_guard import ( + record_nous_rate_limit, + clear_nous_rate_limit, + nous_rate_limit_remaining, + _state_path, + ) + + record_nous_rate_limit(headers={"retry-after": "600"}) + assert nous_rate_limit_remaining() is not None + + clear_nous_rate_limit() + assert nous_rate_limit_remaining() is None + assert not os.path.exists(_state_path()) + + def test_clear_when_no_file(self, rate_guard_env): + from agent.nous_rate_guard import clear_nous_rate_limit + + # Should not raise + clear_nous_rate_limit() + + +class TestFormatRemaining: + """Test human-readable duration formatting.""" + + def test_seconds(self): + from agent.nous_rate_guard import format_remaining + + assert format_remaining(30) == "30s" + + def test_minutes(self): + from agent.nous_rate_guard import format_remaining + + assert format_remaining(125) == "2m 5s" + + def test_exact_minutes(self): + from agent.nous_rate_guard import format_remaining + + assert format_remaining(120) == "2m" + + def test_hours(self): + from agent.nous_rate_guard import format_remaining + + assert format_remaining(3720) == "1h 2m" + + +class TestParseResetSeconds: + """Test header parsing for reset times.""" + + def test_case_insensitive_headers(self, rate_guard_env): + from agent.nous_rate_guard import _parse_reset_seconds + + headers = {"X-Ratelimit-Reset-Requests-1h": "1200"} + assert _parse_reset_seconds(headers) == 1200.0 + + def test_returns_none_for_empty_headers(self): + from agent.nous_rate_guard import _parse_reset_seconds + + assert _parse_reset_seconds(None) is None + assert _parse_reset_seconds({}) is None + + def test_ignores_zero_values(self): + from agent.nous_rate_guard import _parse_reset_seconds + + headers = {"x-ratelimit-reset-requests-1h": "0"} + assert _parse_reset_seconds(headers) is None + + def test_ignores_invalid_values(self): + from agent.nous_rate_guard import _parse_reset_seconds + + headers = {"x-ratelimit-reset-requests-1h": "not-a-number"} + assert _parse_reset_seconds(headers) is None + + +class TestAuxiliaryClientIntegration: + """Test that the auxiliary client respects the rate guard.""" + + def test_try_nous_skips_when_rate_limited(self, rate_guard_env, monkeypatch): + from agent.nous_rate_guard import record_nous_rate_limit + + # Record a rate limit + record_nous_rate_limit(headers={"retry-after": "600"}) + + # Mock _read_nous_auth to return valid creds (would normally succeed) + import agent.auxiliary_client as aux + monkeypatch.setattr(aux, "_read_nous_auth", lambda: { + "access_token": "test-token", + "inference_base_url": "https://api.nous.test/v1", + }) + + result = aux._try_nous() + assert result == (None, None) + + def test_try_nous_works_when_not_rate_limited(self, rate_guard_env, monkeypatch): + import agent.auxiliary_client as aux + + # No rate limit recorded — _try_nous should proceed normally + # (will return None because no real creds, but won't be blocked + # by the rate guard) + monkeypatch.setattr(aux, "_read_nous_auth", lambda: None) + result = aux._try_nous() + assert result == (None, None) From c483b4cecaa9d80688184f169564563169dc774a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:07:22 -0700 Subject: [PATCH 238/849] fix: use POSIX ps -A instead of BSD -ax for Docker compat (#9723) (#10569) procps-ng 4.0.4 in Docker rejects BSD-style 'ps eww -ax' with a 'must set personality' error, causing find_gateway_pids() to return empty and falsely report the gateway as not running. Fix: replace 'ps eww -ax' with 'ps -A eww'. -A is the POSIX equivalent of BSD -ax (select all processes), and the eww modifiers (show environment + wide output) still work as BSD flags alongside the POSIX -A flag. This preserves the HERMES_HOME= environment visibility needed for profile-aware PID matching. Closes #9723 --- hermes_cli/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 6d46bdde6..d010a601d 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -222,7 +222,7 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals current_cmd = "" else: result = subprocess.run( - ["ps", "eww", "-ax", "-o", "pid=,command="], + ["ps", "-A", "eww", "-o", "pid=,command="], capture_output=True, text=True, timeout=10, From e402906d48e91d50631aba11aa029d08f6ea9556 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:09:41 -0700 Subject: [PATCH 239/849] fix: five HERMES_HOME profile-isolation leaks (#10570) * fix: show correct env var name in provider API key error (#9506) The error message for missing provider API keys dynamically built the env var name as PROVIDER_API_KEY (e.g. ALIBABA_API_KEY), but some providers use different names (alibaba uses DASHSCOPE_API_KEY). Users following the error message set the wrong variable. Fix: look up the actual env var from PROVIDER_REGISTRY before building the error. Falls back to the dynamic name if the registry lookup fails. Closes #9506 * fix: five HERMES_HOME profile-isolation leaks (#5947) Bug A: Thread session_title from session_db to memory provider init kwargs so honcho can derive chat-scoped session keys instead of falling back to cwd-based naming that merges all gateway users into one session. Bug B: Replace 14 hardcoded ~/.hermes/skills/ paths across 10 skill files with HERMES_HOME-aware alternatives (${HERMES_HOME:-$HOME/.hermes} in shell, os.environ.get('HERMES_HOME', ...) in Python). Bug C: install.sh now respects HERMES_HOME env var and adds --hermes-home flag. Previously --dir only set INSTALL_DIR while HERMES_HOME was always hardcoded to $HOME/.hermes. Bug D: Remove hardcoded ~/.hermes/honcho.json fallback in resolve_config_path(). Non-default profiles no longer silently inherit the default profile's honcho config. Falls through to ~/.honcho/config.json (global) instead. Bug E: Guard _edit_skill, _patch_skill, _delete_skill, _write_file, and _remove_file against writing to skills found in external_dirs. Skills outside the local SKILLS_DIR are now read-only from the agent's perspective. Closes #5947 --- plugins/memory/honcho/client.py | 8 +----- run_agent.py | 9 ++++++ scripts/install.sh | 7 ++++- .../hermes-agent/SKILL.md | 2 +- skills/github/github-code-review/SKILL.md | 2 +- .../references/github-api-cheatsheet.md | 2 +- skills/productivity/google-workspace/SKILL.md | 4 +-- skills/red-teaming/godmode/SKILL.md | 6 ++-- .../godmode/references/jailbreak-templates.md | 2 +- .../godmode/references/refusal-detection.md | 2 +- .../godmode/scripts/auto_jailbreak.py | 2 +- .../godmode/scripts/godmode_race.py | 2 +- .../godmode/scripts/load_godmode.py | 2 +- .../godmode/scripts/parseltongue.py | 2 +- tools/skill_manager_tool.py | 28 +++++++++++++++++++ 15 files changed, 58 insertions(+), 22 deletions(-) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 3c779f64f..22cd393a2 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -58,8 +58,7 @@ def resolve_config_path() -> Path: Resolution order: 1. $HERMES_HOME/honcho.json (profile-local, if it exists) - 2. ~/.hermes/honcho.json (default profile — shared host blocks live here) - 3. ~/.honcho/config.json (global, cross-app interop) + 2. ~/.honcho/config.json (global, cross-app interop) Returns the global path if none exist (for first-time setup writes). """ @@ -67,11 +66,6 @@ def resolve_config_path() -> Path: if local_path.exists(): return local_path - # Default profile's config — host blocks accumulate here via setup/clone - default_path = Path.home() / ".hermes" / "honcho.json" - if default_path != local_path and default_path.exists(): - return default_path - return GLOBAL_CONFIG_PATH diff --git a/run_agent.py b/run_agent.py index f199d806d..d332fb6eb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1280,6 +1280,15 @@ class AIAgent: "hermes_home": str(_ghh()), "agent_context": "primary", } + # Thread session title for memory provider scoping + # (e.g. honcho uses this to derive chat-scoped session keys) + if self._session_db: + try: + _st = self._session_db.get_session_title(self.session_id) + if _st: + _init_kwargs["session_title"] = _st + except Exception: + pass # Thread gateway user identity for per-user memory scoping if self._user_id: _init_kwargs["user_id"] = self._user_id diff --git a/scripts/install.sh b/scripts/install.sh index aa6f4f79b..2b943797a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -28,7 +28,7 @@ BOLD='\033[1m' # Configuration REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git" REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git" -HERMES_HOME="$HOME/.hermes" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" INSTALL_DIR="${HERMES_INSTALL_DIR:-$HERMES_HOME/hermes-agent}" PYTHON_VERSION="3.11" NODE_VERSION="22" @@ -66,6 +66,10 @@ while [[ $# -gt 0 ]]; do INSTALL_DIR="$2" shift 2 ;; + --hermes-home) + HERMES_HOME="$2" + shift 2 + ;; -h|--help) echo "Hermes Agent Installer" echo "" @@ -76,6 +80,7 @@ while [[ $# -gt 0 ]]; do echo " --skip-setup Skip interactive setup wizard" echo " --branch NAME Git branch to install (default: main)" echo " --dir PATH Installation directory (default: ~/.hermes/hermes-agent)" + echo " --hermes-home PATH Data directory (default: ~/.hermes, or \$HERMES_HOME)" echo " -h, --help Show this help" exit 0 ;; diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 77e1b1d18..bea9d0a5a 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -313,7 +313,7 @@ Type these during an interactive chat session. ``` ~/.hermes/config.yaml Main configuration ~/.hermes/.env API keys and secrets -~/.hermes/skills/ Installed skills +$HERMES_HOME/skills/ Installed skills ~/.hermes/sessions/ Session transcripts ~/.hermes/logs/ Gateway and error logs ~/.hermes/auth.json OAuth tokens and credential pools diff --git a/skills/github/github-code-review/SKILL.md b/skills/github/github-code-review/SKILL.md index 52d8e4a07..8041fbb6e 100644 --- a/skills/github/github-code-review/SKILL.md +++ b/skills/github/github-code-review/SKILL.md @@ -334,7 +334,7 @@ When the user asks you to "review PR #N", "look at this PR", or gives you a PR U ### Step 1: Set up environment ```bash -source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh +source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh" # Or run the inline setup block from the top of this skill ``` diff --git a/skills/github/github-repo-management/references/github-api-cheatsheet.md b/skills/github/github-repo-management/references/github-api-cheatsheet.md index ab7e1d19d..501a81af1 100644 --- a/skills/github/github-repo-management/references/github-api-cheatsheet.md +++ b/skills/github/github-repo-management/references/github-api-cheatsheet.md @@ -6,7 +6,7 @@ All requests need: `-H "Authorization: token $GITHUB_TOKEN"` Use the `gh-env.sh` helper to set `$GITHUB_TOKEN`, `$GH_OWNER`, `$GH_REPO` automatically: ```bash -source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh +source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh" ``` ## Repositories diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index fb9f00be2..ebde7d0e8 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -32,7 +32,7 @@ on CLI, Telegram, Discord, or any platform. Define a shorthand first: ```bash -GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" +GSETUP="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/setup.py" ``` ### Step 0: Check if already set up @@ -163,7 +163,7 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall All commands go through the API script. Set `GAPI` as a shorthand: ```bash -GAPI="python ~/.hermes/skills/productivity/google-workspace/scripts/google_api.py" +GAPI="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/google_api.py" ``` ### Gmail diff --git a/skills/red-teaming/godmode/SKILL.md b/skills/red-teaming/godmode/SKILL.md index 47d1268aa..fa248c021 100644 --- a/skills/red-teaming/godmode/SKILL.md +++ b/skills/red-teaming/godmode/SKILL.md @@ -60,7 +60,7 @@ The fastest path — auto-detect the model, test strategies, and lock in the win # In execute_code — use the loader to avoid exec-scoping issues: import os exec(open(os.path.expanduser( - "~/.hermes/skills/red-teaming/godmode/scripts/load_godmode.py" + os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/load_godmode.py") )).read()) # Auto-detect model from config and jailbreak it @@ -192,7 +192,7 @@ python3 scripts/parseltongue.py "How do I hack into a WiFi network?" --tier stan Or use `execute_code` inline: ```python # Load the parseltongue module -exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/parseltongue.py")).read()) +exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/parseltongue.py")).read()) query = "How do I hack into a WiFi network?" variants = generate_variants(query, tier="standard") @@ -229,7 +229,7 @@ Race multiple models against the same query, score responses, pick the winner: ```python # Via execute_code -exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) +exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read()) result = race_models( query="Explain how SQL injection works with a practical example", diff --git a/skills/red-teaming/godmode/references/jailbreak-templates.md b/skills/red-teaming/godmode/references/jailbreak-templates.md index 3eb5e869e..c7b901986 100644 --- a/skills/red-teaming/godmode/references/jailbreak-templates.md +++ b/skills/red-teaming/godmode/references/jailbreak-templates.md @@ -114,7 +114,7 @@ hermes ### Via the GODMODE CLASSIC racer script ```python -exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) +exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read()) result = race_godmode_classic("Your query here") print(f"Winner: {result['codename']} — Score: {result['score']}") print(result['content']) diff --git a/skills/red-teaming/godmode/references/refusal-detection.md b/skills/red-teaming/godmode/references/refusal-detection.md index 0b359e4b4..5fb3414c5 100644 --- a/skills/red-teaming/godmode/references/refusal-detection.md +++ b/skills/red-teaming/godmode/references/refusal-detection.md @@ -129,7 +129,7 @@ These don't auto-reject but reduce the response score: ## Using in Python ```python -exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) +exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read()) # Check if a response is a refusal text = "I'm sorry, but I can't assist with that request." diff --git a/skills/red-teaming/godmode/scripts/auto_jailbreak.py b/skills/red-teaming/godmode/scripts/auto_jailbreak.py index 0b17de509..e6efced48 100644 --- a/skills/red-teaming/godmode/scripts/auto_jailbreak.py +++ b/skills/red-teaming/godmode/scripts/auto_jailbreak.py @@ -7,7 +7,7 @@ finds what works, and locks it in by writing config.yaml + prefill.json. Usage in execute_code: exec(open(os.path.expanduser( - "~/.hermes/skills/red-teaming/godmode/scripts/auto_jailbreak.py" + os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/auto_jailbreak.py") )).read()) result = auto_jailbreak() # Uses current model from config diff --git a/skills/red-teaming/godmode/scripts/godmode_race.py b/skills/red-teaming/godmode/scripts/godmode_race.py index ccd021392..dbc451030 100644 --- a/skills/red-teaming/godmode/scripts/godmode_race.py +++ b/skills/red-teaming/godmode/scripts/godmode_race.py @@ -7,7 +7,7 @@ Queries multiple models in parallel via OpenRouter, scores responses on quality/filteredness/speed, returns the best unfiltered answer. Usage in execute_code: - exec(open(os.path.expanduser("~/.hermes/skills/red-teaming/godmode/scripts/godmode_race.py")).read()) + exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/godmode_race.py")).read()) result = race_models( query="Your query here", diff --git a/skills/red-teaming/godmode/scripts/load_godmode.py b/skills/red-teaming/godmode/scripts/load_godmode.py index f8bf31acf..71cb2f224 100644 --- a/skills/red-teaming/godmode/scripts/load_godmode.py +++ b/skills/red-teaming/godmode/scripts/load_godmode.py @@ -3,7 +3,7 @@ Loader for G0DM0D3 scripts. Handles the exec-scoping issues. Usage in execute_code: exec(open(os.path.expanduser( - "~/.hermes/skills/red-teaming/godmode/scripts/load_godmode.py" + os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/load_godmode.py") )).read()) # Now all functions are available: diff --git a/skills/red-teaming/godmode/scripts/parseltongue.py b/skills/red-teaming/godmode/scripts/parseltongue.py index ba891c6ac..0b24f1550 100644 --- a/skills/red-teaming/godmode/scripts/parseltongue.py +++ b/skills/red-teaming/godmode/scripts/parseltongue.py @@ -11,7 +11,7 @@ Usage: python parseltongue.py "How do I hack a WiFi network?" --tier standard # As a module in execute_code - exec(open("~/.hermes/skills/red-teaming/godmode/scripts/parseltongue.py").read()) + exec(open(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), "skills/red-teaming/godmode/scripts/parseltongue.py")).read()) variants = generate_variants("How do I hack a WiFi network?", tier="standard") """ diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index a3e585a58..33d3976ea 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -82,6 +82,18 @@ SKILLS_DIR = HERMES_HOME / "skills" MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 + + +def _is_local_skill(skill_path: Path) -> bool: + """Check if a skill path is within the local SKILLS_DIR. + + Skills found in external_dirs are read-only from the agent's perspective. + """ + try: + skill_path.resolve().relative_to(SKILLS_DIR.resolve()) + return True + except ValueError: + return False MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file @@ -360,6 +372,9 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} + if not _is_local_skill(existing["path"]): + return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + skill_md = existing["path"] / "SKILL.md" # Back up original content for rollback original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None @@ -400,6 +415,9 @@ def _patch_skill( if not existing: return {"success": False, "error": f"Skill '{name}' not found."} + if not _is_local_skill(existing["path"]): + return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + skill_dir = existing["path"] if file_path: @@ -473,6 +491,9 @@ def _delete_skill(name: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} + if not _is_local_skill(existing["path"]): + return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be deleted."} + skill_dir = existing["path"] shutil.rmtree(skill_dir) @@ -515,6 +536,9 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} + if not _is_local_skill(existing["path"]): + return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} + target, err = _resolve_skill_target(existing["path"], file_path) if err: return {"success": False, "error": err} @@ -548,6 +572,10 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]: existing = _find_skill(name) if not existing: return {"success": False, "error": f"Skill '{name}' not found."} + + if not _is_local_skill(existing["path"]): + return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified."} + skill_dir = existing["path"] target, err = _resolve_skill_target(skill_dir, file_path) From 63d045b51af985f9a0d6840b02917128b5869d33 Mon Sep 17 00:00:00 2001 From: shin4 <42616633+shin4@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:13:11 +0800 Subject: [PATCH 240/849] fix: pass HERMES_HOME to execute_code subprocess (#6644) Add "HERMES_" to _SAFE_ENV_PREFIXES in code_execution_tool.py so HERMES_HOME and other Hermes env vars pass through to execute_code subprocesses. Fixes vision_analyze and other tools that rely on get_hermes_home() failing in Docker environments with non-default HERMES_HOME. Authored by @shin4. --- tools/code_execution_tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index bed4f2091..723bc400d 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -988,7 +988,8 @@ def execute_code( # (terminal.env_passthrough) are passed through. _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", - "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA") + "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", + "HERMES_") _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "PASSWD", "AUTH") try: From 4fdcae6c91cd85cc7361d76bb44bf7bc5a9f92c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:22:55 -0700 Subject: [PATCH 241/849] fix: use absolute skill_dir for external skills (#10313) (#10587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_skill_payload() reconstructed skill_dir as SKILLS_DIR / relative_path, which is wrong for external skills from skills.external_dirs — they live outside SKILLS_DIR entirely. Scripts and linked files failed to load. Fix: skill_view() now includes the absolute skill_dir in its result dict. _load_skill_payload() uses that directly when available, falling back to the SKILLS_DIR-relative reconstruction only for legacy responses. Closes #10313 --- agent/skill_commands.py | 9 ++++++++- tools/skills_tool.py | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 149b4aaeb..280105dac 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -72,7 +72,14 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu skill_name = str(loaded_skill.get("name") or normalized) skill_path = str(loaded_skill.get("path") or "") skill_dir = None - if skill_path: + # Prefer the absolute skill_dir returned by skill_view() — this is + # correct for both local and external skills. Fall back to the old + # SKILLS_DIR-relative reconstruction only when skill_dir is absent + # (e.g. legacy skill_view responses). + abs_skill_dir = loaded_skill.get("skill_dir") + if abs_skill_dir: + skill_dir = Path(abs_skill_dir) + elif skill_path: try: skill_dir = SKILLS_DIR / Path(skill_path).parent except Exception: diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 340e4ed53..ed8c8cfb0 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1263,6 +1263,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: "related_skills": related_skills, "content": content, "path": rel_path, + "skill_dir": str(skill_dir) if skill_dir else None, "linked_files": linked_files if linked_files else None, "usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files From 44941f0ed15b221490860768f9548f0bba63ccf1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:22:58 -0700 Subject: [PATCH 242/849] fix: activate WeCom callback message deduplication (#10305) (#10588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WecomCallbackAdapter declared a _seen_messages dict and MESSAGE_DEDUP_TTL_SECONDS constant but never actually checked them in _handle_callback(). WeCom retries callback deliveries on timeout, and each retry with the same MsgId was treated as a fresh message and queued for processing. Fix: check _seen_messages before enqueuing. Uses the same TTL- based pattern as MessageDeduplicator (fixed in #10306) — check age before returning duplicate, prune on overflow. Closes #10305 --- gateway/platforms/wecom_callback.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gateway/platforms/wecom_callback.py b/gateway/platforms/wecom_callback.py index 4bb67d5cf..5440792de 100644 --- a/gateway/platforms/wecom_callback.py +++ b/gateway/platforms/wecom_callback.py @@ -258,6 +258,20 @@ class WecomCallbackAdapter(BasePlatformAdapter): ) event = self._build_event(app, decrypted) if event is not None: + # Deduplicate: WeCom retries callbacks on timeout, + # producing duplicate inbound messages (#10305). + if event.message_id: + now = time.time() + if event.message_id in self._seen_messages: + if now - self._seen_messages[event.message_id] < MESSAGE_DEDUP_TTL_SECONDS: + logger.debug("[WecomCallback] Duplicate MsgId %s, skipping", event.message_id) + return web.Response(text="success", content_type="text/plain") + del self._seen_messages[event.message_id] + self._seen_messages[event.message_id] = now + # Prune expired entries when cache grows large + if len(self._seen_messages) > 2000: + cutoff = now - MESSAGE_DEDUP_TTL_SECONDS + self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff} # Record which app this user belongs to. if event.source and event.source.user_id: map_key = self._user_app_key( From 33ff29dfae3f8941d5dca717f9aa36db0f9ba505 Mon Sep 17 00:00:00 2001 From: Greer Guthrie Date: Wed, 15 Apr 2026 16:40:38 -0700 Subject: [PATCH 243/849] fix(gateway): defer background review notifications until after main reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background review notifications ("💾 Skill created", "💾 Memory updated") could race ahead of the main assistant reply in chat, making it look like the agent stopped after creating a skill. Gate bg-review notifications behind a threading.Event + pending queue. Register a release callback on the adapter's _post_delivery_callbacks dict so base.py's finally block fires it after the main response is delivered. The queued-message path in _run_agent pops and calls the callback directly to prevent double-fire. Co-authored-by: Hermes Agent Closes #10541 --- gateway/platforms/base.py | 13 ++++ gateway/run.py | 43 ++++++++++++- tests/gateway/test_run_progress_topics.py | 76 +++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index c718cce89..ddee844f4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -839,6 +839,11 @@ class BasePlatformAdapter(ABC): # Gateway shutdown cancels these so an old gateway instance doesn't keep # working on a task after --replace or manual restarts. self._background_tasks: set[asyncio.Task] = set() + # One-shot callbacks to fire after the main response is delivered. + # Keyed by session_key. GatewayRunner uses this to defer + # background-review notifications ("💾 Skill created") until the + # primary reply has been sent. + self._post_delivery_callbacks: Dict[str, Callable] = {} self._expected_cancelled_tasks: set[asyncio.Task] = set() self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None # Chats where auto-TTS on voice input is disabled (set by /voice off) @@ -1894,6 +1899,14 @@ class BasePlatformAdapter(ABC): except Exception: pass # Last resort — don't let error reporting crash the handler finally: + # Fire any one-shot post-delivery callback registered for this + # session (e.g. deferred background-review notifications). + _post_cb = getattr(self, "_post_delivery_callbacks", {}).pop(session_key, None) + if callable(_post_cb): + try: + _post_cb() + except Exception: + pass # Stop typing indicator typing_task.cancel() try: diff --git a/gateway/run.py b/gateway/run.py index a95ca159b..16027bfd3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8616,8 +8616,11 @@ class GatewayRunner: agent.service_tier = self._service_tier agent.request_overrides = turn_route.get("request_overrides") - # Background review delivery — send "💾 Memory updated" etc. to user - def _bg_review_send(message: str) -> None: + _bg_review_release = threading.Event() + _bg_review_pending: list[str] = [] + _bg_review_pending_lock = threading.Lock() + + def _deliver_bg_review_message(message: str) -> None: if not _status_adapter: return try: @@ -8632,7 +8635,32 @@ class GatewayRunner: except Exception as _e: logger.debug("background_review_callback error: %s", _e) + def _release_bg_review_messages() -> None: + _bg_review_release.set() + with _bg_review_pending_lock: + pending = list(_bg_review_pending) + _bg_review_pending.clear() + for queued in pending: + _deliver_bg_review_message(queued) + + # Background review delivery — send "💾 Memory updated" etc. to user + def _bg_review_send(message: str) -> None: + if not _status_adapter: + return + if not _bg_review_release.is_set(): + with _bg_review_pending_lock: + if not _bg_review_release.is_set(): + _bg_review_pending.append(message) + return + _deliver_bg_review_message(message) + agent.background_review_callback = _bg_review_send + # Register the release hook on the adapter so base.py's finally + # block can fire it after delivering the main response. + if _status_adapter and session_key: + _pdc = getattr(_status_adapter, "_post_delivery_callbacks", None) + if _pdc is not None: + _pdc[session_key] = _release_bg_review_messages # Store agent reference for interrupt support agent_holder[0] = agent @@ -9356,6 +9384,17 @@ class GatewayRunner: ) except Exception as e: logger.warning("Failed to send first response before queued message: %s", e) + # Release deferred bg-review notifications now that the + # first response has been delivered. Pop from the + # adapter's callback dict (prevents double-fire in + # base.py's finally block) and call it. + if adapter and hasattr(adapter, "_post_delivery_callbacks"): + _bg_cb = adapter._post_delivery_callbacks.pop(session_key, None) + if callable(_bg_cb): + try: + _bg_cb() + except Exception: + pass # else: interrupted — discard the interrupted response ("Operation # interrupted." is just noise; the user already knows they sent a # new message). diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 1b7829616..4878f2fae 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -1,5 +1,6 @@ """Tests for topic-aware gateway progress updates.""" +import asyncio import importlib import sys import time @@ -415,6 +416,21 @@ class QueuedCommentaryAgent: } +class BackgroundReviewAgent: + def __init__(self, **kwargs): + self.background_review_callback = kwargs.get("background_review_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + if self.background_review_callback: + self.background_review_callback("💾 Skill 'prospect-scanner' created.") + return { + "final_response": "done", + "messages": [], + "api_calls": 1, + } + + class VerboseAgent: """Agent that emits a tool call with args whose JSON exceeds 200 chars.""" LONG_CODE = "x" * 300 @@ -668,6 +684,66 @@ async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monke assert "final response 1" in sent_texts +@pytest.mark.asyncio +async def test_run_agent_defers_background_review_notification_until_release(monkeypatch, tmp_path): + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + BackgroundReviewAgent, + session_id="sess-bg-review-order", + config_data={"display": {"interim_assistant_messages": True}}, + ) + + assert result["final_response"] == "done" + assert adapter.sent == [] + + +@pytest.mark.asyncio +async def test_base_processing_releases_post_delivery_callback_after_main_send(): + """Post-delivery callbacks on the adapter fire after the main response.""" + adapter = ProgressCaptureAdapter() + + async def _handler(event): + return "done" + + adapter.set_message_handler(_handler) + + released = [] + + def _post_delivery_cb(): + released.append(True) + adapter.sent.append( + { + "chat_id": "bg-review", + "content": "💾 Skill 'prospect-scanner' created.", + "reply_to": None, + "metadata": None, + } + ) + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001", + chat_type="group", + thread_id="17585", + ) + event = MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=source, + message_id="msg-1", + ) + session_key = "agent:main:telegram:group:-1001:17585" + adapter._active_sessions[session_key] = asyncio.Event() + adapter._post_delivery_callbacks[session_key] = _post_delivery_cb + + await adapter._process_message_background(event, session_key) + + sent_texts = [call["content"] for call in adapter.sent] + assert sent_texts == ["done", "💾 Skill 'prospect-scanner' created."] + assert released == [True] + + @pytest.mark.asyncio async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path): """Verbose mode with default tool_preview_length (0) should NOT truncate args. From 933fbd8feac5716da39a879feae7ba2560f70bf5 Mon Sep 17 00:00:00 2001 From: handsdiff <239876380+handsdiff@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:43:21 -0400 Subject: [PATCH 244/849] fix: prevent agent hang when backgrounding processes via terminal tool bash -lic with a PTY enables job control (set -m), which waits for all background jobs before the shell exits. A command like `python3 -m http.server &>/dev/null &` hangs forever because the shell never completes. Prefix `set +m;` to disable job control while keeping -i for .bashrc sourcing and PTY for interactive tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/process_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/process_registry.py b/tools/process_registry.py index 3a274eaa3..2dbcdd150 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -334,7 +334,7 @@ class ProcessRegistry: pty_env = _sanitize_subprocess_env(os.environ, env_vars) pty_env["PYTHONUNBUFFERED"] = "1" pty_proc = _PtyProcessCls.spawn( - [user_shell, "-lic", command], + [user_shell, "-lic", f"set +m; {command}"], cwd=session.cwd, env=pty_env, dimensions=(30, 120), @@ -375,7 +375,7 @@ class ProcessRegistry: bg_env = _sanitize_subprocess_env(os.environ, env_vars) bg_env["PYTHONUNBUFFERED"] = "1" proc = subprocess.Popen( - [user_shell, "-lic", command], + [user_shell, "-lic", f"set +m; {command}"], text=True, cwd=session.cwd, env=bg_env, From a6ad8ace29ebd425b4aa76b0744ae34667ffd883 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 17:10:02 -0700 Subject: [PATCH 245/849] chore: add handsdiff to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 0c021633b..5f7c7a0d9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -83,6 +83,7 @@ AUTHOR_MAP = { "4317663+helix4u@users.noreply.github.com": "helix4u", "331214+counterposition@users.noreply.github.com": "counterposition", "blspear@gmail.com": "BrennerSpear", + "239876380+handsdiff@users.noreply.github.com": "handsdiff", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", From b750c720cdd3a0c04d5c5fe4829ddb0ba577d85a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:34:15 -0700 Subject: [PATCH 246/849] fix: three CLI quality-of-life fixes (#10468, #10230, #10526, #9545) (#10599) Three independent fixes batched together: 1. hermes auth add crashes on non-interactive stdin (#10468) input() for the label prompt was called without checking isatty(). In scripted/CI environments this raised EOFError. Fix: check sys.stdin.isatty() and fall back to the computed default label. 2. Subcommand help prints twice (#10230) 'hermes dashboard -h' printed help text twice because the SystemExit(0) from argparse was caught by the fallback retry logic, which re-parsed and printed help again. Fix: re-raise SystemExit with code 0 (help/version) immediately. 3. Duplicate entries in /model picker (#10526, #9545) - Kimi showed 2x because kimi-coding and kimi-coding-cn both mapped to the same models.dev ID. Fix: track seen mdev_ids and skip aliases. - Providers could show 2-3x from case-variant slugs across the four loading paths. Fix: normalize all seen_slugs membership checks and insertions to lowercase. Closes #10468, #10230, #10526, #9545 --- hermes_cli/auth_commands.py | 6 +++++- hermes_cli/main.py | 7 ++++++- hermes_cli/model_switch.py | 27 +++++++++++++++++---------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index c6e23b42f..20d028200 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -4,6 +4,7 @@ from __future__ import annotations from getpass import getpass import math +import sys import time from types import SimpleNamespace import uuid @@ -160,7 +161,10 @@ def auth_add_command(args) -> None: default_label = _api_key_default_label(len(pool.entries()) + 1) label = (getattr(args, "label", None) or "").strip() if not label: - label = input(f"Label (optional, default: {default_label}): ").strip() or default_label + if sys.stdin.isatty(): + label = input(f"Label (optional, default: {default_label}): ").strip() or default_label + else: + label = default_label entry = PooledCredential( provider=provider, id=uuid.uuid4().hex[:6], diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 638f2a31c..5c6db4e90 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6325,8 +6325,13 @@ Examples: sys.stderr = _io.StringIO() args = parser.parse_args(_processed_argv) sys.stderr = _saved_stderr - except SystemExit: + except SystemExit as exc: sys.stderr = _saved_stderr + # Help/version flags (exit code 0) already printed output — + # re-raise immediately to avoid a second parse_args printing + # the same help text again (#10230). + if exc.code == 0: + raise # Subcommand name was consumed as a flag value (e.g. -c model). # Fall back to optional subparsers so argparse handles it normally. subparsers.required = False diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 699bde23e..dee0cb23d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -786,7 +786,8 @@ def list_authenticated_providers( from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS results: List[dict] = [] - seen_slugs: set = set() + seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) + seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) data = fetch_models_dev() @@ -799,6 +800,11 @@ def list_authenticated_providers( # --- 1. Check Hermes-mapped providers --- for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): + # Skip aliases that map to the same models.dev provider (e.g. + # kimi-coding and kimi-coding-cn both → kimi-for-coding). + # The first one with valid credentials wins (#10526). + if mdev_id in seen_mdev_ids: + continue pdata = data.get(mdev_id) if not isinstance(pdata, dict): continue @@ -837,7 +843,8 @@ def list_authenticated_providers( "total_models": total, "source": "built-in", }) - seen_slugs.add(slug) + seen_slugs.add(slug.lower()) + seen_mdev_ids.add(mdev_id) # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) --- from hermes_cli.providers import HERMES_OVERLAYS @@ -849,12 +856,12 @@ def list_authenticated_providers( _mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()} for pid, overlay in HERMES_OVERLAYS.items(): - if pid in seen_slugs: + if pid.lower() in seen_slugs: continue # Resolve Hermes slug — e.g. "github-copilot" → "copilot" hermes_slug = _mdev_to_hermes.get(pid, pid) - if hermes_slug in seen_slugs: + if hermes_slug.lower() in seen_slugs: continue # Check if credentials exist @@ -935,8 +942,8 @@ def list_authenticated_providers( "total_models": total, "source": "hermes", }) - seen_slugs.add(pid) - seen_slugs.add(hermes_slug) + seen_slugs.add(pid.lower()) + seen_slugs.add(hermes_slug.lower()) # --- 2b. Cross-check canonical provider list --- # Catches providers that are in CANONICAL_PROVIDERS but weren't found @@ -948,7 +955,7 @@ def list_authenticated_providers( _canon_provs = [] for _cp in _canon_provs: - if _cp.slug in seen_slugs: + if _cp.slug.lower() in seen_slugs: continue # Check credentials via PROVIDER_REGISTRY (auth.py) @@ -995,7 +1002,7 @@ def list_authenticated_providers( "total_models": _cp_total, "source": "canonical", }) - seen_slugs.add(_cp.slug) + seen_slugs.add(_cp.slug.lower()) # --- 3. User-defined endpoints from config --- if user_providers and isinstance(user_providers, dict): @@ -1068,7 +1075,7 @@ def list_authenticated_providers( groups[slug]["models"].append(default_model) for slug, grp in groups.items(): - if slug in seen_slugs: + if slug.lower() in seen_slugs: continue results.append({ "slug": slug, @@ -1080,7 +1087,7 @@ def list_authenticated_providers( "source": "user-config", "api_url": grp["api_url"], }) - seen_slugs.add(slug) + seen_slugs.add(slug.lower()) # Sort: current provider first, then by model count descending results.sort(key=lambda r: (not r["is_current"], -r["total_models"])) From 55c80986010880af92660645477585abfcbb4241 Mon Sep 17 00:00:00 2001 From: Joshua Santos <47019696+MrNiceRicee@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:37:05 -0700 Subject: [PATCH 247/849] docs: update openai-codex setup reference (#10471) Fixes stale openai-codex onboarding reference in cli-config.yaml.example --- cli-config.yaml.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 657423679..7ba6e6731 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -16,7 +16,7 @@ model: # "nous" - Nous Portal OAuth (requires: hermes login) # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY) # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY) - # "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex) + # "openai-codex" - OpenAI Codex (requires: hermes auth) # "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN) # "gemini" - Use Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) # "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY) From 276ed5c399d247022e5b033808daade2c8969ae1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 17:35:52 -0700 Subject: [PATCH 248/849] fix(send_message): deliver Matrix media via adapter Matrix media delivery was silently dropped by send_message because Matrix wasn't wired into the native adapter-backed media path. Only Telegram, Discord, and Weixin had native media support. Adds _send_matrix_via_adapter() which creates a MatrixAdapter instance, connects, sends text + media via the adapter's native upload methods (send_document, send_image_file, send_video, send_voice), then disconnects. Also fixes a stale URL-encoding assertion in test_send_message_missing_platforms that broke after PR #10151 added quote() to room IDs. Cherry-picked from PR #10486 by helix4u. --- .../test_send_message_missing_platforms.py | 2 +- tests/tools/test_send_message_tool.py | 79 ++++++++++++++++++ tools/send_message_tool.py | 81 ++++++++++++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index a6741e16d..cda43aad2 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -123,7 +123,7 @@ class TestSendMatrix: session.put.assert_called_once() call_kwargs = session.put.call_args url = call_kwargs[0][0] - assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/") + assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/%21room%3Aexample.com/send/m.room.message/") assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok" payload = call_kwargs[1]["json"] assert payload["msgtype"] == "m.text" diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 07a1a9beb..17c95d797 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -12,6 +12,7 @@ from gateway.config import Platform from tools.send_message_tool import ( _parse_target_ref, _send_discord, + _send_matrix_via_adapter, _send_telegram, _send_to_platform, send_message_tool, @@ -594,6 +595,84 @@ class TestSendToPlatformChunking: assert all(call == [] for call in sent_calls[:-1]) assert sent_calls[-1] == media + def test_matrix_media_uses_native_adapter_helper(self): + + doc_path = Path("/tmp/test-send-message-matrix.pdf") + doc_path.write_bytes(b"%PDF-1.4 test") + + try: + helper = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:example.com", "message_id": "$evt"}) + with patch("tools.send_message_tool._send_matrix_via_adapter", helper): + result = asyncio.run( + _send_to_platform( + Platform.MATRIX, + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:example.com", + "here you go", + media_files=[(str(doc_path), False)], + ) + ) + + assert result["success"] is True + helper.assert_awaited_once() + call = helper.await_args + assert call.args[1] == "!room:example.com" + assert call.args[2] == "here you go" + assert call.kwargs["media_files"] == [(str(doc_path), False)] + finally: + doc_path.unlink(missing_ok=True) + + def test_send_matrix_via_adapter_sends_document(self, tmp_path): + file_path = tmp_path / "report.pdf" + file_path.write_bytes(b"%PDF-1.4 test") + + calls = [] + + class FakeAdapter: + def __init__(self, _config): + self.connected = False + + async def connect(self): + self.connected = True + calls.append(("connect",)) + return True + + async def send(self, chat_id, message, metadata=None): + calls.append(("send", chat_id, message, metadata)) + return SimpleNamespace(success=True, message_id="$text") + + async def send_document(self, chat_id, file_path, metadata=None): + calls.append(("send_document", chat_id, file_path, metadata)) + return SimpleNamespace(success=True, message_id="$file") + + async def disconnect(self): + calls.append(("disconnect",)) + + fake_module = SimpleNamespace(MatrixAdapter=FakeAdapter) + + with patch.dict(sys.modules, {"gateway.platforms.matrix": fake_module}): + result = asyncio.run( + _send_matrix_via_adapter( + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:example.com", + "report attached", + media_files=[(str(file_path), False)], + ) + ) + + assert result == { + "success": True, + "platform": "matrix", + "chat_id": "!room:example.com", + "message_id": "$file", + } + assert calls == [ + ("connect",), + ("send", "!room:example.com", "report attached", None), + ("send_document", "!room:example.com", str(file_path), None), + ("disconnect",), + ] + # --------------------------------------------------------------------------- # HTML auto-detection in Telegram send diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 1c6417105..cc681adc7 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -404,11 +404,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Matrix: use the native adapter helper for text + media --- + if platform == Platform.MATRIX: + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_matrix_via_adapter( + pconfig, + chat_id, + chunk, + media_files=media_files if is_last else [], + thread_id=thread_id, + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + # --- Non-Telegram/Discord platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram, discord, and weixin; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, and weixin; " f"target {platform.value} had only media attachments" ) } @@ -416,7 +433,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram, discord, and weixin" + "native send_message media delivery is currently only supported for telegram, discord, matrix, and weixin" ) last_result = None @@ -907,6 +924,66 @@ async def _send_matrix(token, extra, chat_id, message): return _error(f"Matrix send failed: {e}") +async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): + """Send via the Matrix adapter so native Matrix media uploads are preserved.""" + try: + from gateway.platforms.matrix import MatrixAdapter + except ImportError: + return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} + + media_files = media_files or [] + + try: + adapter = MatrixAdapter(pconfig) + connected = await adapter.connect() + if not connected: + return _error("Matrix connect failed") + + metadata = {"thread_id": thread_id} if thread_id else None + last_result = None + + if message.strip(): + last_result = await adapter.send(chat_id, message, metadata=metadata) + if not last_result.success: + return _error(f"Matrix send failed: {last_result.error}") + + for media_path, is_voice in media_files: + if not os.path.exists(media_path): + return _error(f"Media file not found: {media_path}") + + ext = os.path.splitext(media_path)[1].lower() + if ext in _IMAGE_EXTS: + last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata) + elif ext in _VIDEO_EXTS: + last_result = await adapter.send_video(chat_id, media_path, metadata=metadata) + elif ext in _VOICE_EXTS and is_voice: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + elif ext in _AUDIO_EXTS: + last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) + else: + last_result = await adapter.send_document(chat_id, media_path, metadata=metadata) + + if not last_result.success: + return _error(f"Matrix media send failed: {last_result.error}") + + if last_result is None: + return {"error": "No deliverable text or media remained after processing MEDIA tags"} + + return { + "success": True, + "platform": "matrix", + "chat_id": chat_id, + "message_id": last_result.message_id, + } + except Exception as e: + return _error(f"Matrix send failed: {e}") + finally: + try: + await adapter.disconnect() + except Exception: + pass + + async def _send_homeassistant(token, extra, chat_id, message): """Send via Home Assistant notify service.""" try: From c850a40e4e1226b381aa9d76e71efd97807e7d8d Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 17:36:28 -0700 Subject: [PATCH 249/849] fix: gate Matrix adapter path on media_files presence Text-only Matrix sends should continue using the lightweight _send_matrix() HTTP helper (~100ms). Only route through the heavy MatrixAdapter (full sync + E2EE setup) when media files are present. Adds test verifying text-only messages don't take the adapter path. --- tests/tools/test_send_message_tool.py | 19 +++++++++++++++++++ tools/send_message_tool.py | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 17c95d797..a174cf24f 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -622,6 +622,25 @@ class TestSendToPlatformChunking: finally: doc_path.unlink(missing_ok=True) + def test_matrix_text_only_uses_lightweight_path(self): + """Text-only Matrix sends should NOT go through the heavy adapter path.""" + helper = AsyncMock() + lightweight = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:ex.com", "message_id": "$txt"}) + with patch("tools.send_message_tool._send_matrix_via_adapter", helper), \ + patch("tools.send_message_tool._send_matrix", lightweight): + result = asyncio.run( + _send_to_platform( + Platform.MATRIX, + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:ex.com", + "just text, no files", + ) + ) + + assert result["success"] is True + helper.assert_not_awaited() + lightweight.assert_awaited_once() + def test_send_matrix_via_adapter_sends_document(self, tmp_path): file_path = tmp_path / "report.pdf" file_path.write_bytes(b"%PDF-1.4 test") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index cc681adc7..8c673c170 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -404,8 +404,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result - # --- Matrix: use the native adapter helper for text + media --- - if platform == Platform.MATRIX: + # --- Matrix: use the native adapter helper when media is present --- + if platform == Platform.MATRIX and media_files: last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) From 5ef0fe1665611ebe81235ddec3e5e74a9fc1993e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:43:54 -0700 Subject: [PATCH 250/849] docs: fix stale hermes login references in hermes-agent skill (#10603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #10471 — replace remaining 'hermes login --provider' references with current 'hermes auth' flow. --- skills/autonomous-ai-agents/hermes-agent/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index bea9d0a5a..362841f39 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -351,8 +351,8 @@ Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/con |----------|------|-------------| | OpenRouter | API key | `OPENROUTER_API_KEY` | | Anthropic | API key | `ANTHROPIC_API_KEY` | -| Nous Portal | OAuth | `hermes login --provider nous` | -| OpenAI Codex | OAuth | `hermes login --provider openai-codex` | +| Nous Portal | OAuth | `hermes auth` | +| OpenAI Codex | OAuth | `hermes auth` | | GitHub Copilot | Token | `COPILOT_GITHUB_TOKEN` | | Google Gemini | API key | `GOOGLE_API_KEY` or `GEMINI_API_KEY` | | DeepSeek | API key | `DEEPSEEK_API_KEY` | From 77435c4f13858ebfe3f71c9cc902f20d4647fe1e Mon Sep 17 00:00:00 2001 From: Xowiek Date: Wed, 15 Apr 2026 22:27:36 +0300 Subject: [PATCH 251/849] fix(gateway): use profile-aware Hermes paths in runtime hints --- gateway/run.py | 3 ++- gateway/session.py | 8 ++++++-- scripts/release.py | 1 + tests/cli/test_personality_none.py | 12 ++++++++++++ tests/gateway/test_session.py | 13 +++++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 16027bfd3..2d907e08a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4990,6 +4990,7 @@ class GatewayRunner: async def _handle_personality_command(self, event: MessageEvent) -> str: """Handle /personality command - list or set a personality.""" import yaml + from hermes_constants import display_hermes_home args = event.get_command_args().strip().lower() config_path = _hermes_home / 'config.yaml' @@ -5007,7 +5008,7 @@ class GatewayRunner: personalities = {} if not personalities: - return "No personalities configured in `~/.hermes/config.yaml`" + return f"No personalities configured in `{display_hermes_home()}/config.yaml`" if not args: lines = ["🎭 **Available Personalities**\n"] diff --git a/gateway/session.py b/gateway/session.py index 33165dcd9..c14e9bd03 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -301,6 +301,8 @@ def build_session_context_prompt( lines.append("") lines.append("**Delivery options for scheduled tasks:**") + from hermes_constants import display_hermes_home + # Origin delivery if context.source.platform == Platform.LOCAL: lines.append("- `\"origin\"` → Local output (saved to files)") @@ -309,9 +311,11 @@ def build_session_context_prompt( _hash_chat_id(context.source.chat_id) if redact_pii else context.source.chat_id ) lines.append(f"- `\"origin\"` → Back to this chat ({_origin_label})") - + # Local always available - lines.append("- `\"local\"` → Save to local files only (~/.hermes/cron/output/)") + lines.append( + f"- `\"local\"` → Save to local files only ({display_hermes_home()}/cron/output/)" + ) # Platform home channels for platform, home in context.home_channels.items(): diff --git a/scripts/release.py b/scripts/release.py index 5f7c7a0d9..53d42ea05 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -125,6 +125,7 @@ AUTHOR_MAP = { "balyan.sid@gmail.com": "balyansid", "oluwadareab12@gmail.com": "bennytimz", "simon@simonmarcus.org": "simon-marcus", + "xowiekk@gmail.com": "Xowiek", "1243352777@qq.com": "zons-zhaozhy", # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply # crossref, and GH contributor list matching (April 2026 audit) ── diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index ec27838fe..ad5e87e88 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -144,6 +144,18 @@ class TestGatewayPersonalityNone: assert "none" in result.lower() + @pytest.mark.asyncio + async def test_empty_personality_list_uses_profile_display_path(self, tmp_path): + runner = self._make_runner(personalities={}) + (tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}})) + + with patch("gateway.run._hermes_home", tmp_path), \ + patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"): + event = self._make_event("") + result = await runner._handle_personality_command(event) + + assert result == "No personalities configured in `~/.hermes/profiles/coder/config.yaml`" + class TestPersonalityDictFormat: """Test dict-format custom personalities with description, tone, style.""" diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 50bc7c046..39e4aad3d 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -283,6 +283,19 @@ class TestBuildSessionContextPrompt: assert "Local" in prompt assert "machine running this agent" in prompt + def test_local_delivery_path_uses_display_hermes_home(self): + config = GatewayConfig() + source = SessionSource( + platform=Platform.LOCAL, chat_id="cli", + chat_name="CLI terminal", chat_type="dm", + ) + ctx = build_session_context(source, config) + + with patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"): + prompt = build_session_context_prompt(ctx) + + assert "~/.hermes/profiles/coder/cron/output/" in prompt + def test_whatsapp_prompt(self): config = GatewayConfig( platforms={ From 21cd3a3fc055af8b06cea9fc444bde4061a16a77 Mon Sep 17 00:00:00 2001 From: Xowiek Date: Wed, 15 Apr 2026 17:38:41 -0700 Subject: [PATCH 252/849] fix(profile): use existing get_active_profile_name() for /profile command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline Path.home() / '.hermes' / 'profiles' detection in both CLI and gateway /profile handlers with the existing get_active_profile_name() from hermes_cli.profiles — which already handles custom-root deployments, standard profiles, and Docker layouts. Fixes /profile incorrectly reporting 'default' when HERMES_HOME points to a custom-root profile path like /opt/data/profiles/coder. Based on PR #10484 by Xowiek. --- cli.py | 17 ++++------------ gateway/run.py | 29 +++++++--------------------- tests/cli/test_cli_status_command.py | 16 +++++++++++++++ tests/gateway/test_status_command.py | 25 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 35 deletions(-) diff --git a/cli.py b/cli.py index fbc8f8525..20996aecc 100644 --- a/cli.py +++ b/cli.py @@ -3897,23 +3897,14 @@ class HermesCLI: def _handle_profile_command(self): """Display active profile name and home directory.""" - from hermes_constants import get_hermes_home, display_hermes_home + from hermes_constants import display_hermes_home + from hermes_cli.profiles import get_active_profile_name - home = get_hermes_home() display = display_hermes_home() - - profiles_parent = Path.home() / ".hermes" / "profiles" - try: - rel = home.relative_to(profiles_parent) - profile_name = str(rel).split("/")[0] - except ValueError: - profile_name = None + profile_name = get_active_profile_name() print() - if profile_name: - print(f" Profile: {profile_name}") - else: - print(" Profile: default") + print(f" Profile: {profile_name}") print(f" Home: {display}") print() diff --git a/gateway/run.py b/gateway/run.py index 2d907e08a..94381d8be 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4393,31 +4393,16 @@ class GatewayRunner: async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show active profile name and home directory.""" - from hermes_constants import get_hermes_home, display_hermes_home - from pathlib import Path + from hermes_constants import display_hermes_home + from hermes_cli.profiles import get_active_profile_name - home = get_hermes_home() display = display_hermes_home() + profile_name = get_active_profile_name() - # Detect profile name from HERMES_HOME path - # Profile paths look like: ~/.hermes/profiles/ - profiles_parent = Path.home() / ".hermes" / "profiles" - try: - rel = home.relative_to(profiles_parent) - profile_name = str(rel).split("/")[0] - except ValueError: - profile_name = None - - if profile_name: - lines = [ - f"👤 **Profile:** `{profile_name}`", - f"📂 **Home:** `{display}`", - ] - else: - lines = [ - "👤 **Profile:** default", - f"📂 **Home:** `{display}`", - ] + lines = [ + f"👤 **Profile:** `{profile_name}`", + f"📂 **Home:** `{display}`", + ] return "\n".join(lines) diff --git a/tests/cli/test_cli_status_command.py b/tests/cli/test_cli_status_command.py index bff642fdf..ed6fbd7d2 100644 --- a/tests/cli/test_cli_status_command.py +++ b/tests/cli/test_cli_status_command.py @@ -1,5 +1,6 @@ """Tests for CLI /status command behavior.""" from datetime import datetime +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -83,3 +84,18 @@ def test_show_session_status_prints_gateway_style_summary(): _, kwargs = cli_obj.console.print.call_args assert kwargs.get("highlight") is False assert kwargs.get("markup") is False + + +def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path, capsys): + """Profile detection works for custom-root deployments (not under ~/.hermes).""" + cli_obj = _make_cli() + profile_home = tmp_path / "profiles" / "coder" + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "unrelated-home") + + cli_obj._handle_profile_command() + + out = capsys.readouterr().out + assert "Profile: coder" in out + assert f"Home: {profile_home}" in out diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 0dbd5980b..554480087 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -209,3 +209,28 @@ async def test_status_command_bypasses_active_session_guard(): assert "Agent Running" in sent[0] assert not interrupt_event.is_set(), "/status incorrectly triggered an agent interrupt" assert session_key not in adapter._pending_messages, "/status was incorrectly queued" + + +@pytest.mark.asyncio +async def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path): + """Gateway /profile detects custom-root profiles (not under ~/.hermes).""" + from pathlib import Path + + 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", + ) + runner = _make_runner(session_entry) + profile_home = tmp_path / "profiles" / "coder" + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "unrelated-home") + + result = await runner._handle_profile_command(_make_event("/profile")) + + assert "**Profile:** `coder`" in result + assert f"**Home:** `{profile_home}`" in result From 5d3a81408d8196d87780d397f8973637c3d09431 Mon Sep 17 00:00:00 2001 From: cuyua9 <2114364329@qq.com> Date: Thu, 16 Apr 2026 01:09:19 +0800 Subject: [PATCH 253/849] docs: document Telegram ignored threads --- website/docs/reference/environment-variables.md | 1 + website/docs/user-guide/messaging/telegram.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 8167b353e..bf6022bd8 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -169,6 +169,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) | | `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram | | `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) | +| `TELEGRAM_IGNORED_THREADS` | Comma-separated Telegram forum topic/thread IDs where the bot never responds | | `DISCORD_BOT_TOKEN` | Discord bot token | | `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot | | `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 7fc965bcb..4292ae4f6 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -228,6 +228,7 @@ Hermes Agent works in Telegram group chats with a few considerations: - replies to one of the bot's messages - `@botusername` mentions - matches for one of your configured regex wake words in `telegram.mention_patterns` +- Use `telegram.ignored_threads` to keep Hermes silent in specific Telegram forum topics, even when the group would otherwise allow free responses or mention-triggered replies - If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see ### Example group trigger configuration @@ -239,9 +240,13 @@ telegram: require_mention: true mention_patterns: - "^\\s*chompy\\b" + ignored_threads: + - 31 + - "42" ``` This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`. +Messages in Telegram topics `31` and `42` are always ignored before the mention and free-response checks run. ### Notes on `mention_patterns` From e7c61baaa15644345a852d40513d49ef24216c75 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:00:39 -0600 Subject: [PATCH 254/849] fix: include telegram dependency in termux bundle --- pyproject.toml | 1 + website/docs/getting-started/termux.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0d84b5e1e..d696457b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ termux = [ # Tested Android / Termux path: keeps the core CLI feature-rich while # avoiding extras that currently depend on non-Android wheels (notably # faster-whisper -> ctranslate2 via the voice extra). + "python-telegram-bot[webhooks]>=22.6,<23", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[pty]", diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index eb860f85c..a272bd256 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -16,6 +16,7 @@ The tested Termux bundle installs: - the Hermes CLI - cron support - PTY/background terminal support +- Telegram gateway support (manual / best-effort background runs) - MCP support - Honcho memory support - ACP support @@ -34,6 +35,7 @@ A few features still need desktop/server-style dependencies that are not publish - the `voice` extra is blocked by `faster-whisper -> ctranslate2`, and `ctranslate2` does not publish Android wheels - automatic browser / Playwright bootstrap is skipped in the Termux installer - Docker-based terminal isolation is not available inside Termux +- Android may still suspend Termux background jobs, so gateway persistence is best-effort rather than a normal managed service That does not stop Hermes from working well as a phone-native CLI agent — it just means the recommended mobile install is intentionally narrower than the desktop/server install. From c6398fcaab596ee41404cb09e27dc098d09803b9 Mon Sep 17 00:00:00 2001 From: flobo3 Date: Wed, 15 Apr 2026 20:43:55 +0300 Subject: [PATCH 255/849] fix(prompt): list all supported Telegram markdown formatting --- agent/prompt_builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index c61d6995b..e7bb0ffc9 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -295,7 +295,9 @@ PLATFORM_HINTS = { ), "telegram": ( "You are on a text messaging communication platform, Telegram. " - "Please do not use markdown as it does not render. " + "Standard markdown is automatically converted to Telegram format. " + "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " + "`inline code`, ```code blocks```, [links](url), and ## headers. " "You can send media files natively: to deliver a file to the user, " "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " From 92a23479c06fca902a51d697c60bf17d1159fbe1 Mon Sep 17 00:00:00 2001 From: Roque Date: Fri, 10 Apr 2026 06:38:27 -0600 Subject: [PATCH 256/849] fix(model-switch): normalize Unicode dashes from Telegram/iOS input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram on iOS auto-converts double hyphens (--) to em dashes (—) or en dashes (–) via autocorrect. This breaks /model flag parsing since parse_model_flags() only recognizes literal '--provider' and '--global'. When the flag isn't parsed, the entire string (e.g. 'glm-5.1 —provider zai') gets treated as the model name and fails with 'Model names cannot contain spaces.' Fix: normalize Unicode dashes (U+2012-U+2015) to '--' when they appear before flag keywords (provider, global), before flag extraction. The existing test suite in test_model_switch_provider_routing.py already covers all four dash variants — this commit adds the code that makes them pass. --- gateway/run.py | 5 ++ hermes_cli/model_switch.py | 5 ++ tests/gateway/test_insights_unicode_flags.py | 54 ++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 tests/gateway/test_insights_unicode_flags.py diff --git a/gateway/run.py b/gateway/run.py index 94381d8be..f0320ef61 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6586,6 +6586,11 @@ class GatewayRunner: import asyncio as _asyncio args = event.get_command_args().strip() + + # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash) + import re as _re + args = _re.sub(r'[\u2012\u2013\u2014\u2015](days|source)', r'--\1', args) + days = 30 source = None diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index dee0cb23d..11c2fa06a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -274,6 +274,11 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool]: is_global = False explicit_provider = "" + # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash) + # A single Unicode dash before a flag keyword becomes "--" + import re as _re + raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global)', r'--\1', raw_args) + # Extract --global if "--global" in raw_args: is_global = True diff --git a/tests/gateway/test_insights_unicode_flags.py b/tests/gateway/test_insights_unicode_flags.py new file mode 100644 index 000000000..28e9a2378 --- /dev/null +++ b/tests/gateway/test_insights_unicode_flags.py @@ -0,0 +1,54 @@ +"""Tests for Unicode dash normalization in /insights command flag parsing. + +Telegram on iOS auto-converts -- to em/en dashes. The /insights handler +normalizes these before parsing --days and --source flags. +""" +import re +import pytest + + +# The regex from gateway/run.py insights handler +_UNICODE_DASH_RE = re.compile(r'[\u2012\u2013\u2014\u2015](days|source)') + + +def _normalize_insights_args(raw: str) -> str: + """Apply the same normalization as the /insights handler.""" + return _UNICODE_DASH_RE.sub(r'--\1', raw) + + +class TestInsightsUnicodeDashFlags: + """--days and --source must survive iOS Unicode dash conversion.""" + + @pytest.mark.parametrize("input_str,expected", [ + # Standard double hyphen (baseline) + ("--days 7", "--days 7"), + ("--source telegram", "--source telegram"), + # Em dash (U+2014) + ("\u2014days 7", "--days 7"), + ("\u2014source telegram", "--source telegram"), + # En dash (U+2013) + ("\u2013days 7", "--days 7"), + ("\u2013source telegram", "--source telegram"), + # Figure dash (U+2012) + ("\u2012days 7", "--days 7"), + # Horizontal bar (U+2015) + ("\u2015days 7", "--days 7"), + # Combined flags with em dashes + ("\u2014days 30 \u2014source cli", "--days 30 --source cli"), + ]) + def test_unicode_dash_normalized(self, input_str, expected): + result = _normalize_insights_args(input_str) + assert result == expected + + def test_regular_hyphens_unaffected(self): + """Normal --days/--source must pass through unchanged.""" + assert _normalize_insights_args("--days 7 --source discord") == "--days 7 --source discord" + + def test_bare_number_still_works(self): + """Shorthand /insights 7 (no flag) must not be mangled.""" + assert _normalize_insights_args("7") == "7" + + def test_no_flags_unchanged(self): + """Input with no flags passes through as-is.""" + assert _normalize_insights_args("") == "" + assert _normalize_insights_args("30") == "30" From 63548e4fe1c15f69a14fa0432e8355b7d7385f27 Mon Sep 17 00:00:00 2001 From: "Mil Wang (from Dev Box)" Date: Wed, 15 Apr 2026 08:57:15 +0800 Subject: [PATCH 257/849] fix: validate Telegram bot token format during gateway setup (#9843) The setup wizard accepted any string as a Telegram bot token without validation. Invalid tokens were only caught at runtime when the gateway failed to connect, with no clear error message. Add regex validation for the expected format (:) and loop until a valid token is entered or the user cancels. --- hermes_cli/setup.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 9044871dc..52f6e36d6 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1611,9 +1611,19 @@ def _setup_telegram(): return print_info("Create a bot via @BotFather on Telegram") - token = prompt("Telegram bot token", password=True) - if not token: - return + import re + + while True: + token = prompt("Telegram bot token", password=True) + if not token: + return + if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token): + print_error( + "Invalid token format. Expected: : " + "(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)" + ) + continue + break save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") From 4936b1914429d6283141f6e4f5872dfff86e49cd Mon Sep 17 00:00:00 2001 From: jneeee Date: Wed, 15 Apr 2026 17:41:16 -0700 Subject: [PATCH 258/849] fix(cron): guard telegram import in _send_to_platform against ImportError Wrap the TelegramAdapter import in _send_to_platform() with a try/except ImportError guard, matching the existing Feishu pattern in the same function. When python-telegram-bot is not installed, the import no longer crashes the cron scheduler. Instead, MAX_MESSAGE_LENGTH falls back to a hardcoded 4096. The _send_telegram() function already had its own ImportError guard for the telegram package; this fixes the remaining bare import of TelegramAdapter in the platform-routing function. --- tools/send_message_tool.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 8c673c170..27edf0eec 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -327,10 +327,16 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, """ from gateway.config import Platform from gateway.platforms.base import BasePlatformAdapter, utf16_len - from gateway.platforms.telegram import TelegramAdapter from gateway.platforms.discord import DiscordAdapter from gateway.platforms.slack import SlackAdapter + # Telegram adapter import is optional (requires python-telegram-bot) + try: + from gateway.platforms.telegram import TelegramAdapter + _telegram_available = True + except ImportError: + _telegram_available = False + # Feishu adapter import is optional (requires lark-oapi) try: from gateway.platforms.feishu import FeishuAdapter @@ -349,7 +355,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, # Platform message length limits (from adapter class attributes) _MAX_LENGTHS = { - Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, + Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, } From 06d6903d3cf16010e89914334aabcceab263d260 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Apr 2026 23:03:34 +0800 Subject: [PATCH 259/849] fix(telegram): escape Markdown special chars in send_exec_approval The command preview and description were wrapped in Markdown v1 inline code (backticks) without escaping, causing Telegram API parse errors when the command itself contained backticks or asterisks. Fixes: 'Can't parse entities: can't find end of the entity' --- gateway/platforms/telegram.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 09af14f34..02e6beb00 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1076,10 +1076,13 @@ class TelegramAdapter(BasePlatformAdapter): try: cmd_preview = command[:3800] + "..." if len(command) > 3800 else command + # Escape backticks that would break Markdown v1 inline code parsing + safe_cmd = cmd_preview.replace("`", "'") + safe_desc = description.replace("`", "'").replace("*", "∗") text = ( f"⚠️ *Command Approval Required*\n\n" - f"`{cmd_preview}`\n\n" - f"Reason: {description}" + f"`{safe_cmd}`\n\n" + f"Reason: {safe_desc}" ) # Resolve thread context for thread replies From aea3499e5659d7e82ff3f80dd516612f6f57bfb5 Mon Sep 17 00:00:00 2001 From: Kovyrin Family Claw Date: Sun, 12 Apr 2026 22:02:47 -0400 Subject: [PATCH 260/849] feat(telegram): add config option to disable link previews --- gateway/platforms/telegram.py | 31 +++++++++++++++++++ .../gateway/test_telegram_approval_buttons.py | 21 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 02e6beb00..0334fdca5 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -18,6 +18,10 @@ logger = logging.getLogger(__name__) try: from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup + try: + from telegram import LinkPreviewOptions + except ImportError: + LinkPreviewOptions = None from telegram.ext import ( Application, CommandHandler, @@ -36,6 +40,7 @@ except ImportError: Message = Any InlineKeyboardButton = Any InlineKeyboardMarkup = Any + LinkPreviewOptions = None Application = Any CommandHandler = Any CallbackQueryHandler = Any @@ -137,6 +142,7 @@ class TelegramAdapter(BasePlatformAdapter): self._webhook_mode: bool = False self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' + self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False) # Buffer rapid/album photo updates so Telegram image bursts are handled # as a single MessageEvent instead of self-interrupting multiple turns. self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8")) @@ -202,6 +208,26 @@ class TelegramAdapter(BasePlatformAdapter): pass return isinstance(error, OSError) + def _coerce_bool_extra(self, key: str, default: bool = False) -> bool: + value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None + if value is None: + return default + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return default + return bool(value) + + def _link_preview_kwargs(self) -> Dict[str, Any]: + if not self._disable_link_previews: + return {} + if LinkPreviewOptions is not None: + return {"link_preview_options": LinkPreviewOptions(is_disabled=True)} + return {"disable_web_page_preview": True} + async def _handle_polling_network_error(self, error: Exception) -> None: """Reconnect polling after a transient network interruption. @@ -856,6 +882,7 @@ class TelegramAdapter(BasePlatformAdapter): parse_mode=ParseMode.MARKDOWN_V2, reply_to_message_id=reply_to_id, message_thread_id=effective_thread_id, + **self._link_preview_kwargs(), ) except Exception as md_error: # Markdown parsing failed, try plain text @@ -868,6 +895,7 @@ class TelegramAdapter(BasePlatformAdapter): parse_mode=None, reply_to_message_id=reply_to_id, message_thread_id=effective_thread_id, + **self._link_preview_kwargs(), ) else: raise @@ -1055,6 +1083,7 @@ class TelegramAdapter(BasePlatformAdapter): text=text, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard, + **self._link_preview_kwargs(), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1114,6 +1143,7 @@ class TelegramAdapter(BasePlatformAdapter): "text": text, "parse_mode": ParseMode.MARKDOWN, "reply_markup": keyboard, + **self._link_preview_kwargs(), } if thread_id: kwargs["message_thread_id"] = int(thread_id) @@ -1184,6 +1214,7 @@ class TelegramAdapter(BasePlatformAdapter): parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard, message_thread_id=int(thread_id) if thread_id else None, + **self._link_preview_kwargs(), ) # Store picker state keyed by chat_id diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index ec5bbd47e..93b5f82ee 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -50,9 +50,9 @@ from gateway.platforms.telegram import TelegramAdapter from gateway.config import Platform, PlatformConfig -def _make_adapter(): +def _make_adapter(extra=None): """Create a TelegramAdapter with mocked internals.""" - config = PlatformConfig(enabled=True, token="test-token") + config = PlatformConfig(enabled=True, token="test-token", extra=extra or {}) adapter = TelegramAdapter(config) adapter._bot = AsyncMock() adapter._app = MagicMock() @@ -134,6 +134,23 @@ class TestTelegramExecApproval: ) assert result.success is False + @pytest.mark.asyncio + async def test_disable_link_previews_sets_preview_kwargs(self): + adapter = _make_adapter(extra={"disable_link_previews": True}) + mock_msg = MagicMock() + mock_msg.message_id = 42 + adapter._bot.send_message = AsyncMock(return_value=mock_msg) + + await adapter.send_exec_approval( + chat_id="12345", command="ls", session_key="s" + ) + + kwargs = adapter._bot.send_message.call_args[1] + assert ( + kwargs.get("disable_web_page_preview") is True + or kwargs.get("link_preview_options") is not None + ) + @pytest.mark.asyncio async def test_truncates_long_command(self): adapter = _make_adapter() From 5221ff9ed139b1a468b8f5066942e75e24908c14 Mon Sep 17 00:00:00 2001 From: Oleksiy Kovyrin Date: Sun, 12 Apr 2026 22:43:14 -0400 Subject: [PATCH 261/849] fix(telegram): tolerate bare adapters in link preview helper --- gateway/platforms/telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0334fdca5..54e79b395 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -222,7 +222,7 @@ class TelegramAdapter(BasePlatformAdapter): return bool(value) def _link_preview_kwargs(self) -> Dict[str, Any]: - if not self._disable_link_previews: + if not getattr(self, "_disable_link_previews", False): return {} if LinkPreviewOptions is not None: return {"link_preview_options": LinkPreviewOptions(is_disabled=True)} From 192ef00bb2eca43ffe4707e9f1ca466aa2988afc Mon Sep 17 00:00:00 2001 From: Oleksiy Kovyrin Date: Sun, 12 Apr 2026 22:47:53 -0400 Subject: [PATCH 262/849] docs(config): document telegram link preview setting --- cli-config.yaml.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 7ba6e6731..962b554b4 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -564,6 +564,18 @@ platform_toolsets: homeassistant: [hermes-homeassistant] qqbot: [hermes-qqbot] +# ============================================================================= +# Gateway Platform Settings +# ============================================================================= +# Optional per-platform messaging settings. +# Platform-specific knobs live under `extra`. +# +# platforms: +# telegram: +# reply_to_mode: "first" # off | first | all +# extra: +# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages + # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) # From 00ff9a26cd174328c70bb7f1bdae0cb0881941d9 Mon Sep 17 00:00:00 2001 From: Kovyrin Family Claw Date: Mon, 13 Apr 2026 11:53:12 -0400 Subject: [PATCH 263/849] Fix Telegram link preview suppression for bot sends --- gateway/config.py | 10 ++++++++++ tests/gateway/test_config.py | 16 ++++++++++++++++ tests/tools/test_send_message_tool.py | 13 ++++++++++++- tools/send_message_tool.py | 6 +++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 72fde982a..0f8afc22a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -638,6 +638,16 @@ def load_gateway_config() -> GatewayConfig: os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"): os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() + if "disable_link_previews" in telegram_cfg: + plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[Platform.TELEGRAM.value] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + extra["disable_link_previews"] = telegram_cfg["disable_link_previews"] whatsapp_cfg = yaml_cfg.get("whatsapp", {}) if isinstance(whatsapp_cfg, dict): diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 1496c6766..1b5a2c530 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -284,6 +284,22 @@ class TestLoadGatewayConfig: assert config.unauthorized_dm_behavior == "ignore" assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + def test_bridges_telegram_disable_link_previews_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " disable_link_previews: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.TELEGRAM].extra["disable_link_previews"] is True + class TestHomeChannelEnvOverrides: """Home channel env vars should apply even when the platform was already diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index a174cf24f..8b4241300 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -577,7 +577,7 @@ class TestSendToPlatformChunking: sent_calls = [] - async def fake_send(token, chat_id, message, media_files=None, thread_id=None): + async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False): sent_calls.append(media_files or []) return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))} @@ -756,6 +756,17 @@ class TestSendTelegramHtmlDetection: kwargs = bot.send_message.await_args.kwargs assert kwargs["parse_mode"] == "MarkdownV2" + def test_disable_link_previews_sets_disable_web_page_preview(self, monkeypatch): + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + asyncio.run( + _send_telegram("tok", "123", "https://example.com", disable_link_previews=True) + ) + + kwargs = bot.send_message.await_args.kwargs + assert kwargs["disable_web_page_preview"] is True + def test_html_with_code_and_pre_tags(self, monkeypatch): bot = self._make_bot() _install_telegram_mock(monkeypatch, bot) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 27edf0eec..782155c83 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -375,6 +375,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, # --- Telegram: special handling for media attachments --- if platform == Platform.TELEGRAM: last_result = None + disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews")) for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) result = await _send_telegram( @@ -383,6 +384,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, chunk, media_files=media_files if is_last else [], thread_id=thread_id, + disable_link_previews=disable_link_previews, ) if isinstance(result, dict) and result.get("error"): return result @@ -484,7 +486,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, return last_result -async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None): +async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False): """Send via Telegram Bot API (one-shot, no polling needed). Applies markdown→MarkdownV2 formatting (same as the gateway adapter) @@ -520,6 +522,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No thread_kwargs = {} if thread_id is not None: thread_kwargs["message_thread_id"] = int(thread_id) + if disable_link_previews: + thread_kwargs["disable_web_page_preview"] = True last_msg = None warnings = [] From cc6e8941dbd7d9887f2aa7d2e23281d946f83309 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:12:19 -0700 Subject: [PATCH 264/849] feat(honcho): context injection overhaul, 5-tool surface, cost safety, session isolation (#10619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #9884 by erosika. Cherry-picked plugin changes onto current main with minimal core modifications. Plugin changes (plugins/memory/honcho/): - New honcho_reasoning tool (5th tool, splits LLM calls from honcho_context) - Two-layer context injection: base context (summary + representation + card) on contextCadence, dialectic supplement on dialecticCadence - Multi-pass dialectic depth (1-3 passes) with early bail-out on strong signal - Cold/warm prompt selection based on session state - dialecticCadence defaults to 3 (was 1) — ~66% fewer Honcho LLM calls - Session summary injection for conversational continuity - Bidirectional peer targeting on all 5 tools - Correctness fixes: peer param fallback, None guard on set_peer_card, schema validation, signal_sufficient anchored regex, mid->medium level fix Core changes (~20 lines across 3 files): - agent/memory_manager.py: Enhanced sanitize_context() to strip full blocks and system notes (prevents leak from saveMessages) - run_agent.py: gateway_session_key param for stable per-chat Honcho sessions, on_turn_start() call before prefetch_all() for cadence tracking, sanitize_context() on user messages to strip leaked memory blocks - gateway/run.py: skip_memory=True on 2 temp agents (prevents orphan sessions), gateway_session_key threading to main agent Tests: 509 passed (3 skipped — honcho SDK not installed locally) Docs: Updated honcho.md, memory-providers.md, tools-reference.md, SKILL.md Co-authored-by: erosika --- agent/memory_manager.py | 16 +- gateway/run.py | 3 + .../autonomous-ai-agents/honcho/SKILL.md | 215 +++++- plugins/memory/honcho/README.md | 306 +++++--- plugins/memory/honcho/__init__.py | 487 ++++++++++-- plugins/memory/honcho/cli.py | 125 ++- plugins/memory/honcho/client.py | 151 +++- plugins/memory/honcho/session.py | 322 ++++++-- run_agent.py | 27 +- tests/agent/test_memory_provider.py | 71 ++ tests/honcho_plugin/test_cli.py | 56 ++ tests/honcho_plugin/test_client.py | 292 +++++-- tests/honcho_plugin/test_session.py | 731 +++++++++++++++++- tests/run_agent/test_run_agent.py | 60 ++ website/docs/reference/tools-reference.md | 2 +- website/docs/user-guide/features/honcho.md | 125 ++- .../user-guide/features/memory-providers.md | 39 +- 17 files changed, 2632 insertions(+), 396 deletions(-) create mode 100644 tests/honcho_plugin/test_cli.py diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 6cd1c860b..2435c3f24 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -28,6 +28,7 @@ Usage in run_agent.py: from __future__ import annotations +import json import logging import re from typing import Any, Dict, List, Optional @@ -43,11 +44,22 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- _FENCE_TAG_RE = re.compile(r'', re.IGNORECASE) +_INTERNAL_CONTEXT_RE = re.compile( + r'<\s*memory-context\s*>[\s\S]*?', + re.IGNORECASE, +) +_INTERNAL_NOTE_RE = re.compile( + r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as informational background data\.\]\s*', + re.IGNORECASE, +) def sanitize_context(text: str) -> str: - """Strip fence-escape sequences from provider output.""" - return _FENCE_TAG_RE.sub('', text) + """Strip fence tags, injected context blocks, and system notes from provider output.""" + text = _INTERNAL_CONTEXT_RE.sub('', text) + text = _INTERNAL_NOTE_RE.sub('', text) + text = _FENCE_TAG_RE.sub('', text) + return text def build_memory_context_block(raw_context: str) -> str: diff --git a/gateway/run.py b/gateway/run.py index f0320ef61..67ec4d420 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3739,6 +3739,7 @@ class GatewayRunner: model=_hyg_model, max_iterations=4, quiet_mode=True, + skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) @@ -6221,6 +6222,7 @@ class GatewayRunner: model=model, max_iterations=4, quiet_mode=True, + skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) @@ -8588,6 +8590,7 @@ class GatewayRunner: session_id=session_id, platform=platform_key, user_id=source.user_id, + gateway_session_key=session_key, session_db=self._session_db, fallback_model=self._fallback_model, ) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index 174eaa5d4..c60d2c635 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -1,12 +1,12 @@ --- name: honcho -description: Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, and dialectic reasoning. Use when setting up Honcho, troubleshooting memory, managing profiles with Honcho peers, or tuning observation and recall settings. -version: 1.0.0 +description: Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, dialectic reasoning, session summaries, and context budget enforcement. Use when setting up Honcho, troubleshooting memory, managing profiles with Honcho peers, or tuning observation, recall, and dialectic settings. +version: 2.0.0 author: Hermes Agent license: MIT metadata: hermes: - tags: [Honcho, Memory, Profiles, Observation, Dialectic, User-Modeling] + tags: [Honcho, Memory, Profiles, Observation, Dialectic, User-Modeling, Session-Summary] homepage: https://docs.honcho.dev related_skills: [hermes-agent] prerequisites: @@ -22,8 +22,9 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is - Setting up Honcho (cloud or self-hosted) - Troubleshooting memory not working / peers not syncing - Creating multi-profile setups where each agent has its own Honcho peer -- Tuning observation, recall, or write frequency settings -- Understanding what the 4 Honcho tools do and when to use them +- Tuning observation, recall, dialectic depth, or write frequency settings +- Understanding what the 5 Honcho tools do and when to use them +- Configuring context budgets and session summary injection ## Setup @@ -51,6 +52,27 @@ hermes honcho status # shows resolved config, connection test, peer info ## Architecture +### Base Context Injection + +When Honcho injects context into the system prompt (in `hybrid` or `context` recall modes), it assembles the base context block in this order: + +1. **Session summary** -- a short digest of the current session so far (placed first so the model has immediate conversational continuity) +2. **User representation** -- Honcho's accumulated model of the user (preferences, facts, patterns) +3. **AI peer card** -- the identity card for this Hermes profile's AI peer + +The session summary is generated automatically by Honcho at the start of each turn (when a prior session exists). It gives the model a warm start without replaying full history. + +### Cold / Warm Prompt Selection + +Honcho automatically selects between two prompt strategies: + +| Condition | Strategy | What happens | +|-----------|----------|--------------| +| No prior session or empty representation | **Cold start** | Lightweight intro prompt; skips summary injection; encourages the model to learn about the user | +| Existing representation and/or session history | **Warm start** | Full base context injection (summary → representation → card); richer system prompt | + +You do not need to configure this -- it is automatic based on session state. + ### Peers Honcho models conversations as interactions between **peers**. Hermes creates two peers per session: @@ -112,6 +134,63 @@ How the agent accesses Honcho memory: | `context` | Yes | No (hidden) | Minimal token cost, no tool calls | | `tools` | No | Yes | Agent controls all memory access explicitly | +## Three Orthogonal Knobs + +Honcho's dialectic behavior is controlled by three independent dimensions. Each can be tuned without affecting the others: + +### Cadence (when) + +Controls **how often** dialectic and context calls happen. + +| Key | Default | Description | +|-----|---------|-------------| +| `contextCadence` | `1` | Min turns between context API calls | +| `dialecticCadence` | `3` | Min turns between dialectic API calls | +| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection | + +Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn. + +### Depth (how many) + +Controls **how many rounds** of dialectic reasoning Honcho performs per query. + +| Key | Default | Range | Description | +|-----|---------|-------|-------------| +| `dialecticDepth` | `1` | 1-3 | Number of dialectic reasoning rounds per query | +| `dialecticDepthLevels` | -- | array | Optional per-depth-round level overrides (see below) | + +`dialecticDepth: 2` means Honcho runs two rounds of dialectic synthesis. The first round produces an initial answer; the second refines it. + +`dialecticDepthLevels` lets you set the reasoning level for each round independently: + +```json +{ + "dialecticDepth": 3, + "dialecticDepthLevels": ["low", "medium", "high"] +} +``` + +If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived from `dialecticReasoningLevel` (the base): + +| Depth | Pass levels | +|-------|-------------| +| 1 | [base] | +| 2 | [minimal, base] | +| 3 | [minimal, base, low] | + +This keeps earlier passes cheap while using full depth on the final synthesis. + +### Level (how hard) + +Controls the **intensity** of each dialectic reasoning round. + +| Key | Default | Description | +|-----|---------|-------------| +| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | When `true`, the model can pass `reasoning_level` to `honcho_reasoning` to override the default per-call. `false` = always use `dialecticReasoningLevel`, model overrides ignored | + +Higher levels produce richer synthesis but cost more tokens on Honcho's backend. + ## Multi-Profile Setup Each Hermes profile gets its own Honcho AI peer while sharing the same workspace (user context). This means: @@ -149,6 +228,7 @@ Override any setting in the host block: "hermes.coder": { "aiPeer": "coder", "recallMode": "tools", + "dialecticDepth": 2, "observation": { "user": { "observeMe": true, "observeOthers": false }, "ai": { "observeMe": true, "observeOthers": true } @@ -160,19 +240,97 @@ Override any setting in the host block: ## Tools -The agent has 4 Honcho tools (hidden in `context` recall mode): +The agent has 5 bidirectional Honcho tools (hidden in `context` recall mode): + +| Tool | LLM call? | Cost | Use when | +|------|-----------|------|----------| +| `honcho_profile` | No | minimal | Quick factual snapshot at conversation start or for fast name/role/pref lookups | +| `honcho_search` | No | low | Fetch specific past facts to reason over yourself — raw excerpts, no synthesis | +| `honcho_context` | No | low | Full session context snapshot: summary, representation, card, recent messages | +| `honcho_reasoning` | Yes | medium–high | Natural language question synthesized by Honcho's dialectic engine | +| `honcho_conclude` | No | minimal | Write or delete a persistent fact; pass `peer: "ai"` for AI self-knowledge | ### `honcho_profile` -Quick factual snapshot of the user -- name, role, preferences, patterns. No LLM call, minimal cost. Use at conversation start or for fast lookups. +Read or update a peer card — curated key facts (name, role, preferences, communication style). Pass `card: [...]` to update; omit to read. No LLM call. ### `honcho_search` -Semantic search over stored context. Returns raw excerpts ranked by relevance, no LLM synthesis. Default 800 tokens, max 2000. Use when you want specific past facts to reason over yourself. +Semantic search over stored context for a specific peer. Returns raw excerpts ranked by relevance, no synthesis. Default 800 tokens, max 2000. Good when you need specific past facts to reason over yourself rather than a synthesized answer. ### `honcho_context` -Natural language question answered by Honcho's dialectic reasoning (LLM call on Honcho's backend). Higher cost, higher quality. Can query about user (default) or the AI peer. +Full session context snapshot from Honcho — session summary, peer representation, peer card, and recent messages. No LLM call. Use when you want to see everything Honcho knows about the current session and peer in one shot. + +### `honcho_reasoning` +Natural language question answered by Honcho's dialectic reasoning engine (LLM call on Honcho's backend). Higher cost, higher quality. Pass `reasoning_level` to control depth: `minimal` (fast/cheap) → `low` → `medium` → `high` → `max` (thorough). Omit to use the configured default (`low`). Use for synthesized understanding of the user's patterns, goals, or current state. ### `honcho_conclude` -Write a persistent fact about the user. Conclusions build the user's profile over time. Use when the user states a preference, corrects you, or shares something to remember. +Write or delete a persistent conclusion about a peer. Pass `conclusion: "..."` to create. Pass `delete_id: "..."` to remove a conclusion (for PII removal — Honcho self-heals incorrect conclusions over time, so deletion is only needed for PII). You MUST pass exactly one of the two. + +### Bidirectional peer targeting + +All 5 tools accept an optional `peer` parameter: +- `peer: "user"` (default) — operates on the user peer +- `peer: "ai"` — operates on this profile's AI peer +- `peer: ""` — any peer ID in the workspace + +Examples: +``` +honcho_profile # read user's card +honcho_profile peer="ai" # read AI peer's card +honcho_reasoning query="What does this user care about most?" +honcho_reasoning query="What are my interaction patterns?" peer="ai" reasoning_level="medium" +honcho_conclude conclusion="Prefers terse answers" +honcho_conclude conclusion="I tend to over-explain code" peer="ai" +honcho_conclude delete_id="abc123" # PII removal +``` + +## Agent Usage Patterns + +Guidelines for Hermes when Honcho memory is active. + +### On conversation start + +``` +1. honcho_profile → fast warmup, no LLM cost +2. If context looks thin → honcho_context (full snapshot, still no LLM) +3. If deep synthesis needed → honcho_reasoning (LLM call, use sparingly) +``` + +Do NOT call `honcho_reasoning` on every turn. Auto-injection already handles ongoing context refresh. Use the reasoning tool only when you genuinely need synthesized insight the base context doesn't provide. + +### When the user shares something to remember + +``` +honcho_conclude conclusion="" +``` + +Good conclusions: "Prefers code examples over prose explanations", "Working on a Rust async project through April 2026" +Bad conclusions: "User said something about Rust" (too vague), "User seems technical" (already in representation) + +### When the user asks about past context / you need to recall specifics + +``` +honcho_search query="" → fast, no LLM, good for specific facts +honcho_context → full snapshot with summary + messages +honcho_reasoning query="" → synthesized answer, use when search isn't enough +``` + +### When to use `peer: "ai"` + +Use AI peer targeting to build and query the agent's own self-knowledge: +- `honcho_conclude conclusion="I tend to be verbose when explaining architecture" peer="ai"` — self-correction +- `honcho_reasoning query="How do I typically handle ambiguous requests?" peer="ai"` — self-audit +- `honcho_profile peer="ai"` — review own identity card + +### When NOT to call tools + +In `hybrid` and `context` modes, base context (user representation + card + session summary) is auto-injected before every turn. Do not re-fetch what was already injected. Call tools only when: +- You need something the injected context doesn't have +- The user explicitly asks you to recall or check memory +- You're writing a conclusion about something new + +### Cadence awareness + +`honcho_reasoning` on the tool side shares the same cost as auto-injection dialectic. After an explicit tool call, the auto-injection cadence resets — avoiding double-charging the same turn. ## Config Reference @@ -191,18 +349,39 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso | `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans | | `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N | | `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` | -| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | -| `dialecticDynamic` | `true` | Auto-bump reasoning by query length. `false` = fixed level | | `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | -| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input | -### Cost-awareness (advanced, root config only) +### Dialectic settings | Key | Default | Description | |-----|---------|-------------| +| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | Auto-bump reasoning by query complexity. `false` = fixed level | +| `dialecticDepth` | `1` | Number of dialectic rounds per query (1-3) | +| `dialecticDepthLevels` | -- | Optional array of per-round levels, e.g. `["low", "high"]` | +| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input | + +### Context budget and injection + +| Key | Default | Description | +|-----|---------|-------------| +| `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `1` | Min turns between dialectic API calls | +| `dialecticCadence` | `3` | Min turns between dialectic LLM calls | + +The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions. + +### Memory-context sanitization + +Honcho sanitizes the `memory-context` block before injection to prevent prompt injection and malformed content: + +- Strips XML/HTML tags from user-authored conclusions +- Normalizes whitespace and control characters +- Truncates individual conclusions that exceed `messageMaxChars` +- Escapes delimiter sequences that could break the system prompt structure + +This fix addresses edge cases where raw user conclusions containing markup or special characters could corrupt the injected context block. ## Troubleshooting @@ -221,6 +400,12 @@ Observation config is synced from the server on each session init. Start a new s ### Messages truncated Messages over `messageMaxChars` (default 25k) are automatically chunked with `[continued]` markers. If you're hitting this often, check if tool results or skill content is inflating message size. +### Context injection too large +If you see warnings about context budget exceeded, lower `contextTokens` or reduce `dialecticDepth`. The session summary is trimmed first when the budget is tight. + +### Session summary missing +Session summary requires at least one prior turn in the current Honcho session. On cold start (new session, no history), the summary is omitted and Honcho uses the cold-start prompt strategy instead. + ## CLI Commands | Command | Description | diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 80cc5a70a..4f8d10ea9 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -1,6 +1,6 @@ # Honcho Memory Provider -AI-native cross-session user modeling with dialectic Q&A, semantic search, peer cards, and persistent conclusions. +AI-native cross-session user modeling with multi-pass dialectic reasoning, session summaries, bidirectional peer tools, and persistent conclusions. > **Honcho docs:** @@ -19,9 +19,86 @@ hermes memory setup # generic picker, also works Or manually: ```bash hermes config set memory.provider honcho -echo "HONCHO_API_KEY=your-key" >> ~/.hermes/.env +echo "HONCHO_API_KEY=***" >> ~/.hermes/.env ``` +## Architecture Overview + +### Two-Layer Context Injection + +Context is injected into the **user message** at API-call time (not the system prompt) to preserve prompt caching. Only a static mode header goes in the system prompt. The injected block is wrapped in `` fences with a system note clarifying it's background data, not new user input. + +Two independent layers, each on its own cadence: + +**Layer 1 — Base context** (refreshed every `contextCadence` turns): +1. **SESSION SUMMARY** — from `session.context(summary=True)`, placed first +2. **User Representation** — Honcho's evolving model of the user +3. **User Peer Card** — key facts snapshot +4. **AI Self-Representation** — Honcho's model of the AI peer +5. **AI Identity Card** — AI peer facts + +**Layer 2 — Dialectic supplement** (fired every `dialecticCadence` turns): +Multi-pass `.chat()` reasoning about the user, appended after base context. + +Both layers are joined, then truncated to fit `contextTokens` budget via `_truncate_to_budget` (tokens × 4 chars, word-boundary safe). + +### Cold Start vs Warm Session Prompts + +Dialectic pass 0 automatically selects its prompt based on session state: + +- **Cold** (no base context cached): "Who is this person? What are their preferences, goals, and working style? Focus on facts that would help an AI assistant be immediately useful." +- **Warm** (base context exists): "Given what's been discussed in this session so far, what context about this user is most relevant to the current conversation? Prioritize active context over biographical facts." + +Not configurable — determined automatically. + +### Dialectic Depth (Multi-Pass Reasoning) + +`dialecticDepth` (1–3, clamped) controls how many `.chat()` calls fire per dialectic cycle: + +| Depth | Passes | Behavior | +|-------|--------|----------| +| 1 | single `.chat()` | Base query only (cold or warm prompt) | +| 2 | audit + synthesis | Pass 0 result is self-audited; pass 1 does targeted synthesis. Conditional bail-out if pass 0 returns strong signal (>300 chars or structured with bullets/sections >100 chars) | +| 3 | audit + synthesis + reconciliation | Pass 2 reconciles contradictions across prior passes into a final synthesis | + +### Proportional Reasoning Levels + +When `dialecticDepthLevels` is not set, each pass uses a proportional level relative to `dialecticReasoningLevel` (the "base"): + +| Depth | Pass levels | +|-------|-------------| +| 1 | [base] | +| 2 | [minimal, base] | +| 3 | [minimal, base, low] | + +Override with `dialecticDepthLevels`: an explicit array of reasoning level strings per pass. + +### Three Orthogonal Dialectic Knobs + +| Knob | Controls | Type | +|------|----------|------| +| `dialecticCadence` | How often — minimum turns between dialectic firings | int | +| `dialecticDepth` | How many — passes per firing (1–3) | int | +| `dialecticReasoningLevel` | How hard — reasoning ceiling per `.chat()` call | string | + +### Input Sanitization + +`run_conversation` strips leaked `` blocks from user input before processing. When `saveMessages` persists a turn that included injected context, the block can reappear in subsequent turns via message history. The sanitizer removes `` blocks plus associated system notes. + +## Tools + +Five bidirectional tools. All accept an optional `peer` parameter (`"user"` or `"ai"`, default `"user"`). + +| Tool | LLM call? | Description | +|------|-----------|-------------| +| `honcho_profile` | No | Peer card — key facts snapshot | +| `honcho_search` | No | Semantic search over stored context (800 tok default, 2000 max) | +| `honcho_context` | No | Full session context: summary, representation, card, messages | +| `honcho_reasoning` | Yes | LLM-synthesized answer via dialectic `.chat()` | +| `honcho_conclude` | No | Write a persistent fact/conclusion about the user | + +Tool visibility depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`. + ## Config Resolution Config is read from the first file that exists: @@ -34,42 +111,128 @@ Config is read from the first file that exists: Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.`. -## Tools - -| Tool | LLM call? | Description | -|------|-----------|-------------| -| `honcho_profile` | No | User's peer card -- key facts snapshot | -| `honcho_search` | No | Semantic search over stored context (800 tok default, 2000 max) | -| `honcho_context` | Yes | LLM-synthesized answer via dialectic reasoning | -| `honcho_conclude` | No | Write a persistent fact about the user | - -Tool availability depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`. +For every key, resolution order is: **host block > root > env var > default**. ## Full Configuration Reference ### Identity & Connection -| Key | Type | Default | Scope | Description | -|-----|------|---------|-------|-------------| -| `apiKey` | string | -- | root / host | API key. Falls back to `HONCHO_API_KEY` env var | -| `baseUrl` | string | -- | root | Base URL for self-hosted Honcho. Local URLs (`localhost`, `127.0.0.1`, `::1`) auto-skip API key auth | -| `environment` | string | `"production"` | root / host | SDK environment mapping | -| `enabled` | bool | auto | root / host | Master toggle. Auto-enables when `apiKey` or `baseUrl` present | -| `workspace` | string | host key | root / host | Honcho workspace ID | -| `peerName` | string | -- | root / host | User peer identity | -| `aiPeer` | string | host key | root / host | AI peer identity | +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var | +| `baseUrl` | string | — | Base URL for self-hosted Honcho. Local URLs auto-skip API key auth | +| `environment` | string | `"production"` | SDK environment mapping | +| `enabled` | bool | auto | Master toggle. Auto-enables when `apiKey` or `baseUrl` present | +| `workspace` | string | host key | Honcho workspace ID. Shared environment — all profiles in the same workspace can see the same user identity and related memories | +| `peerName` | string | — | User peer identity | +| `aiPeer` | string | host key | AI peer identity | ### Memory & Recall -| Key | Type | Default | Scope | Description | -|-----|------|---------|-------|-------------| -| `recallMode` | string | `"hybrid"` | root / host | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` normalizes to `"hybrid"` | -| `observationMode` | string | `"directional"` | root / host | Shorthand preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control | -| `observation` | object | -- | root / host | Per-peer observation config (see below) | +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `recallMode` | string | `"hybrid"` | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` → `"hybrid"` | +| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control | +| `observation` | object | — | Per-peer observation config (see Observation section) | -#### Observation (granular) +### Write Behavior -Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. Set at root or per host block -- each profile can have different observation settings. When present, overrides `observationMode` preset. +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `writeFrequency` | string/int | `"async"` | `"async"` (background), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) | +| `saveMessages` | bool | `true` | Persist messages to Honcho API | + +### Session Resolution + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `sessionStrategy` | string | `"per-directory"` | `"per-directory"`, `"per-session"`, `"per-repo"` (git root), `"global"` | +| `sessionPeerPrefix` | bool | `false` | Prepend peer name to session keys | +| `sessions` | object | `{}` | Manual directory-to-session-name mappings | + +#### Session Name Resolution + +The Honcho session name determines which conversation bucket memory lands in. Resolution follows a priority chain — first match wins: + +| Priority | Source | Example session name | +|----------|--------|---------------------| +| 1 | Manual map (`sessions` config) | `"myproject-main"` | +| 2 | `/title` command (mid-session rename) | `"refactor-auth"` | +| 3 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` | +| 4 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) | +| 5 | `per-repo` strategy | Git root directory name (`hermes-agent`) | +| 6 | `per-directory` strategy | Current directory basename (`src`) | +| 7 | `global` strategy | Workspace name (`hermes`) | + +Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions. + +If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`. + +#### What each strategy produces + +- **`per-directory`** — basename of `$PWD`. Opening hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs. +- **`per-repo`** — git root directory name. All subdirectories within a repo share one session. Falls back to `per-directory` if not inside a git repo. +- **`per-session`** — Hermes session ID (timestamp + hex). Every `hermes` invocation starts a fresh Honcho session. Falls back to `per-directory` if no session ID is available. +- **`global`** — workspace name. One session for everything. Memory accumulates across all directories and runs. + +### Multi-Profile Pattern + +Multiple Hermes profiles can share one workspace while maintaining separate AI identities. Config resolution is **host block > root > env var > default** — host blocks inherit from root, so shared settings only need to be declared once: + +```json +{ + "apiKey": "***", + "workspace": "hermes", + "peerName": "yourname", + "hosts": { + "hermes": { + "aiPeer": "hermes", + "recallMode": "hybrid", + "sessionStrategy": "per-directory" + }, + "hermes.coder": { + "aiPeer": "coder", + "recallMode": "tools", + "sessionStrategy": "per-repo" + } + } +} +``` + +Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad. + +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.` (e.g. `hermes -p coder` → host key `hermes.coder`). + +### Dialectic & Reasoning + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `dialecticDepth` | int | `1` | Passes per dialectic cycle (1–3, clamped). 1=single query, 2=audit+synthesis, 3=audit+synthesis+reconciliation | +| `dialecticDepthLevels` | array | — | Optional array of reasoning level strings per pass. Overrides proportional defaults. Example: `["minimal", "low", "medium"]` | +| `dialecticReasoningLevel` | string | `"low"` | Base reasoning level for `.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` | +| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` | +| `dialecticMaxChars` | int | `600` | Max chars of dialectic result injected into system prompt | +| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k | + +### Token Budgets + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `contextTokens` | int | SDK default | Token budget for `context()` API calls. Also gates prefetch truncation (tokens × 4 chars) | +| `messageMaxChars` | int | `25000` | Max chars per message sent via `add_messages()`. Exceeding this triggers chunking with `[continued]` markers. Honcho cloud limit: 25k | + +### Cadence (Cost Control) + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) | +| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings | +| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context on the first user message only, skip from turn 2 onward) | +| `reasoningLevelCap` | string | — | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` | + +### Observation (Granular) + +Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. When present, overrides `observationMode` preset. ```json "observation": { @@ -85,74 +248,16 @@ Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. Set at root or per host block | `ai.observeMe` | `true` | AI peer self-observation (Honcho builds AI representation) | | `ai.observeOthers` | `true` | AI peer observes user messages (enables cross-peer dialectic) | -Presets for `observationMode`: -- `"directional"` (default): all four booleans `true` +Presets: +- `"directional"` (default): all four `true` - `"unified"`: user `observeMe=true`, AI `observeOthers=true`, rest `false` -Per-profile example -- coder profile observes the user but user doesn't observe coder: +### Hardcoded Limits -```json -"hosts": { - "hermes.coder": { - "observation": { - "user": { "observeMe": true, "observeOthers": false }, - "ai": { "observeMe": true, "observeOthers": true } - } - } -} -``` - -Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced back on session init. - -### Write Behavior - -| Key | Type | Default | Scope | Description | -|-----|------|---------|-------|-------------| -| `writeFrequency` | string or int | `"async"` | root / host | `"async"` (background thread), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) | -| `saveMessages` | bool | `true` | root / host | Whether to persist messages to Honcho API | - -### Session Resolution - -| Key | Type | Default | Scope | Description | -|-----|------|---------|-------|-------------| -| `sessionStrategy` | string | `"per-directory"` | root / host | `"per-directory"`, `"per-session"` (new each run), `"per-repo"` (git root name), `"global"` (single session) | -| `sessionPeerPrefix` | bool | `false` | root / host | Prepend peer name to session keys | -| `sessions` | object | `{}` | root | Manual directory-to-session-name mappings: `{"/path/to/project": "my-session"}` | - -### Token Budgets & Dialectic - -| Key | Type | Default | Scope | Description | -|-----|------|---------|-------|-------------| -| `contextTokens` | int | SDK default | root / host | Token budget for `context()` API calls. Also gates prefetch truncation (tokens x 4 chars) | -| `dialecticReasoningLevel` | string | `"low"` | root / host | Base reasoning level for `peer.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` | -| `dialecticDynamic` | bool | `true` | root / host | Auto-bump reasoning based on query length: `<120` chars = base level, `120-400` = +1, `>400` = +2 (capped at `"high"`). Set `false` to always use `dialecticReasoningLevel` as-is | -| `dialecticMaxChars` | int | `600` | root / host | Max chars of dialectic result injected into system prompt | -| `dialecticMaxInputChars` | int | `10000` | root / host | Max chars for dialectic query input to `peer.chat()`. Honcho cloud limit: 10k | -| `messageMaxChars` | int | `25000` | root / host | Max chars per message sent via `add_messages()`. Messages exceeding this are chunked with `[continued]` markers. Honcho cloud limit: 25k | - -### Cost Awareness (Advanced) - -These are read from the root config object, not the host block. Must be set manually in `honcho.json`. - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context only on turn 0) | -| `contextCadence` | int | `1` | Minimum turns between `context()` API calls | -| `dialecticCadence` | int | `1` | Minimum turns between `peer.chat()` API calls | -| `reasoningLevelCap` | string | -- | Hard cap on auto-bumped reasoning: `"minimal"`, `"low"`, `"mid"`, `"high"` | - -### Hardcoded Limits (Not Configurable) - -| Limit | Value | Location | -|-------|-------|----------| -| Search tool max tokens | 2000 (hard cap), 800 (default) | `__init__.py` handle_tool_call | -| Peer card fetch tokens | 200 | `session.py` get_peer_card | - -## Config Precedence - -For every key, resolution order is: **host block > root > env var > default**. - -Host key derivation: `HERMES_HONCHO_HOST` env > active profile (`hermes.`) > `"hermes"`. +| Limit | Value | +|-------|-------| +| Search tool max tokens | 2000 (hard cap), 800 (default) | +| Peer card fetch tokens | 200 | ## Environment Variables @@ -182,15 +287,16 @@ Host key derivation: `HERMES_HONCHO_HOST` env > active profile (`hermes. active profile (`hermes. str: """Return system prompt text, adapted by recall_mode. - B4: On the FIRST call, fetch and bake the full Honcho context - (user representation, peer card, AI representation, continuity synthesis). - Subsequent calls return the cached block for prompt caching stability. + Returns only the mode header and tool instructions — static text + that doesn't change between turns (prompt-cache friendly). + Live context (representation, card) is injected via prefetch(). """ if self._cron_skipped: return "" @@ -382,24 +473,10 @@ class HonchoMemoryProvider(MemoryProvider): return ( "# Honcho Memory\n" "Active (tools-only mode). Use honcho_profile, honcho_search, " - "honcho_context, and honcho_conclude tools to access user memory." + "honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory." ) return "" - # ----- B4: First-turn context baking ----- - first_turn_block = "" - if self._recall_mode in ("context", "hybrid"): - with self._first_turn_lock: - if self._first_turn_context is None: - # First call — fetch and cache - try: - ctx = self._manager.get_prefetch_context(self._session_key) - self._first_turn_context = self._format_first_turn_context(ctx) if ctx else "" - except Exception as e: - logger.debug("Honcho first-turn context fetch failed: %s", e) - self._first_turn_context = "" - first_turn_block = self._first_turn_context - # ----- B1: adapt text based on recall_mode ----- if self._recall_mode == "context": header = ( @@ -412,7 +489,8 @@ class HonchoMemoryProvider(MemoryProvider): header = ( "# Honcho Memory\n" "Active (tools-only mode). Use honcho_profile for a quick factual snapshot, " - "honcho_search for raw excerpts, honcho_context for synthesized answers, " + "honcho_search for raw excerpts, honcho_context for raw peer context, " + "honcho_reasoning for synthesized answers, " "honcho_conclude to save facts about the user. " "No automatic context injection — you must use tools to access memory." ) @@ -421,16 +499,19 @@ class HonchoMemoryProvider(MemoryProvider): "# Honcho Memory\n" "Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. " "Use honcho_profile for a quick factual snapshot, " - "honcho_search for raw excerpts, honcho_context for synthesized answers, " + "honcho_search for raw excerpts, honcho_context for raw peer context, " + "honcho_reasoning for synthesized answers, " "honcho_conclude to save facts about the user." ) - if first_turn_block: - return f"{header}\n\n{first_turn_block}" return header def prefetch(self, query: str, *, session_id: str = "") -> str: - """Return prefetched dialectic context from background thread. + """Return base context (representation + card) plus dialectic supplement. + + Assembles two layers: + 1. Base context from peer.context() — cached, refreshed on context_cadence + 2. Dialectic supplement — cached, refreshed on dialectic_cadence B1: Returns empty when recall_mode is "tools" (no injection). B5: Respects injection_frequency — "first-turn" returns cached/empty after turn 0. @@ -443,22 +524,95 @@ class HonchoMemoryProvider(MemoryProvider): if self._recall_mode == "tools": return "" - # B5: injection_frequency — if "first-turn" and past first turn, return empty - if self._injection_frequency == "first-turn" and self._turn_count > 0: + # B5: injection_frequency — if "first-turn" and past first turn, return empty. + # _turn_count is 1-indexed (first user message = 1), so > 1 means "past first". + if self._injection_frequency == "first-turn" and self._turn_count > 1: return "" + parts = [] + + # ----- Layer 1: Base context (representation + card) ----- + # On first call, fetch synchronously so turn 1 isn't empty. + # After that, serve from cache and refresh in background on cadence. + with self._base_context_lock: + if self._base_context_cache is None: + # First call — synchronous fetch + try: + ctx = self._manager.get_prefetch_context(self._session_key) + self._base_context_cache = self._format_first_turn_context(ctx) if ctx else "" + self._last_context_turn = self._turn_count + except Exception as e: + logger.debug("Honcho base context fetch failed: %s", e) + self._base_context_cache = "" + base_context = self._base_context_cache + + # Check if background context prefetch has a fresher result + if self._manager: + fresh_ctx = self._manager.pop_context_result(self._session_key) + if fresh_ctx: + formatted = self._format_first_turn_context(fresh_ctx) + if formatted: + with self._base_context_lock: + self._base_context_cache = formatted + base_context = formatted + + if base_context: + parts.append(base_context) + + # ----- Layer 2: Dialectic supplement ----- + # On the very first turn, no queue_prefetch() has run yet so the + # dialectic result is empty. Run with a bounded timeout so a slow + # Honcho connection doesn't block the first response indefinitely. + # On timeout the result is skipped and queue_prefetch() will pick it + # up at the next cadence-allowed turn. + if self._last_dialectic_turn == -999 and query: + _first_turn_timeout = ( + self._config.timeout if self._config and self._config.timeout else 8.0 + ) + _result_holder: list[str] = [] + + def _run_first_turn() -> None: + try: + _result_holder.append(self._run_dialectic_depth(query)) + except Exception as exc: + logger.debug("Honcho first-turn dialectic failed: %s", exc) + + _t = threading.Thread(target=_run_first_turn, daemon=True) + _t.start() + _t.join(timeout=_first_turn_timeout) + if not _t.is_alive(): + first_turn_dialectic = _result_holder[0] if _result_holder else "" + if first_turn_dialectic and first_turn_dialectic.strip(): + with self._prefetch_lock: + self._prefetch_result = first_turn_dialectic + self._last_dialectic_turn = self._turn_count + else: + logger.debug( + "Honcho first-turn dialectic timed out (%.1fs) — " + "will inject at next cadence-allowed turn", + _first_turn_timeout, + ) + # Don't update _last_dialectic_turn: queue_prefetch() will + # retry at the next cadence-allowed turn via the async path. + if self._prefetch_thread and self._prefetch_thread.is_alive(): self._prefetch_thread.join(timeout=3.0) with self._prefetch_lock: - result = self._prefetch_result + dialectic_result = self._prefetch_result self._prefetch_result = "" - if not result: + + if dialectic_result and dialectic_result.strip(): + parts.append(dialectic_result) + + if not parts: return "" + result = "\n\n".join(parts) + # ----- Port #3265: token budget enforcement ----- result = self._truncate_to_budget(result) - return f"## Honcho Context\n{result}" + return result def _truncate_to_budget(self, text: str) -> str: """Truncate text to fit within context_tokens budget if set.""" @@ -475,9 +629,11 @@ class HonchoMemoryProvider(MemoryProvider): return truncated + " …" def queue_prefetch(self, query: str, *, session_id: str = "") -> None: - """Fire a background dialectic query for the upcoming turn. + """Fire background prefetch threads for the upcoming turn. - B5: Checks cadence before firing background threads. + B5: Checks cadence independently for dialectic and context refresh. + Context refresh updates the base layer (representation + card). + Dialectic fires the LLM reasoning supplement. """ if self._cron_skipped: return @@ -488,6 +644,15 @@ class HonchoMemoryProvider(MemoryProvider): if self._recall_mode == "tools": return + # ----- Context refresh (base layer) — independent cadence ----- + if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence: + self._last_context_turn = self._turn_count + try: + self._manager.prefetch_context(self._session_key, query) + except Exception as e: + logger.debug("Honcho context prefetch failed: %s", e) + + # ----- Dialectic prefetch (supplement layer) ----- # B5: cadence check — skip if too soon since last dialectic call if self._dialectic_cadence > 1: if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence: @@ -499,9 +664,7 @@ class HonchoMemoryProvider(MemoryProvider): def _run(): try: - result = self._manager.dialectic_query( - self._session_key, query, peer="user" - ) + result = self._run_dialectic_depth(query) if result and result.strip(): with self._prefetch_lock: self._prefetch_result = result @@ -513,13 +676,140 @@ class HonchoMemoryProvider(MemoryProvider): ) self._prefetch_thread.start() - # Also fire context prefetch if cadence allows - if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence: - self._last_context_turn = self._turn_count - try: - self._manager.prefetch_context(self._session_key, query) - except Exception as e: - logger.debug("Honcho context prefetch failed: %s", e) + # ----- Dialectic depth: multi-pass .chat() with cold/warm prompts ----- + + # Proportional reasoning levels per depth/pass when dialecticDepthLevels + # is not configured. The base level is dialecticReasoningLevel. + # Index: (depth, pass) → level relative to base. + _PROPORTIONAL_LEVELS: dict[tuple[int, int], str] = { + # depth 1: single pass at base level + (1, 0): "base", + # depth 2: pass 0 lighter, pass 1 at base + (2, 0): "minimal", + (2, 1): "base", + # depth 3: pass 0 lighter, pass 1 at base, pass 2 one above minimal + (3, 0): "minimal", + (3, 1): "base", + (3, 2): "low", + } + + _LEVEL_ORDER = ("minimal", "low", "medium", "high", "max") + + def _resolve_pass_level(self, pass_idx: int) -> str: + """Resolve reasoning level for a given pass index. + + Uses dialecticDepthLevels if configured, otherwise proportional + defaults relative to dialecticReasoningLevel. + """ + if self._dialectic_depth_levels and pass_idx < len(self._dialectic_depth_levels): + return self._dialectic_depth_levels[pass_idx] + + base = (self._config.dialectic_reasoning_level if self._config else "low") + mapping = self._PROPORTIONAL_LEVELS.get((self._dialectic_depth, pass_idx)) + if mapping is None or mapping == "base": + return base + return mapping + + def _build_dialectic_prompt(self, pass_idx: int, prior_results: list[str], is_cold: bool) -> str: + """Build the prompt for a given dialectic pass. + + Pass 0: cold start (general user query) or warm (session-scoped). + Pass 1: self-audit / targeted synthesis against gaps from pass 0. + Pass 2: reconciliation / contradiction check across prior passes. + """ + if pass_idx == 0: + if is_cold: + return ( + "Who is this person? What are their preferences, goals, " + "and working style? Focus on facts that would help an AI " + "assistant be immediately useful." + ) + return ( + "Given what's been discussed in this session so far, what " + "context about this user is most relevant to the current " + "conversation? Prioritize active context over biographical facts." + ) + elif pass_idx == 1: + prior = prior_results[-1] if prior_results else "" + return ( + f"Given this initial assessment:\n\n{prior}\n\n" + "What gaps remain in your understanding that would help " + "going forward? Synthesize what you actually know about " + "the user's current state and immediate needs, grounded " + "in evidence from recent sessions." + ) + else: + # pass 2: reconciliation + return ( + f"Prior passes produced:\n\n" + f"Pass 1:\n{prior_results[0] if len(prior_results) > 0 else '(empty)'}\n\n" + f"Pass 2:\n{prior_results[1] if len(prior_results) > 1 else '(empty)'}\n\n" + "Do these assessments cohere? Reconcile any contradictions " + "and produce a final, concise synthesis of what matters most " + "for the current conversation." + ) + + @staticmethod + def _signal_sufficient(result: str) -> bool: + """Check if a dialectic pass returned enough signal to skip further passes. + + Heuristic: a response longer than 100 chars with some structure + (section headers, bullets, or an ordered list) is considered sufficient. + """ + if not result or len(result.strip()) < 100: + return False + # Structured output with sections/bullets is strong signal + if "\n" in result and ( + "##" in result + or "•" in result + or re.search(r"^[*-] ", result, re.MULTILINE) + or re.search(r"^\s*\d+\. ", result, re.MULTILINE) + ): + return True + # Long enough even without structure + return len(result.strip()) > 300 + + def _run_dialectic_depth(self, query: str) -> str: + """Execute up to dialecticDepth .chat() calls with conditional bail-out. + + Cold start (no base context): general user-oriented query. + Warm session (base context exists): session-scoped query. + Each pass is conditional — bails early if prior pass returned strong signal. + Returns the best (usually last) result. + """ + if not self._manager or not self._session_key: + return "" + + is_cold = not self._base_context_cache + results: list[str] = [] + + for i in range(self._dialectic_depth): + if i == 0: + prompt = self._build_dialectic_prompt(0, results, is_cold) + else: + # Skip further passes if prior pass delivered strong signal + if results and self._signal_sufficient(results[-1]): + logger.debug("Honcho dialectic depth %d: pass %d skipped, prior signal sufficient", + self._dialectic_depth, i) + break + prompt = self._build_dialectic_prompt(i, results, is_cold) + + level = self._resolve_pass_level(i) + logger.debug("Honcho dialectic depth %d: pass %d, level=%s, cold=%s", + self._dialectic_depth, i, level, is_cold) + + result = self._manager.dialectic_query( + self._session_key, prompt, + reasoning_level=level, + peer="user", + ) + results.append(result or "") + + # Return the last non-empty result (deepest pass that ran) + for r in reversed(results): + if r and r.strip(): + return r + return "" def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: """Track turn count for cadence and injection_frequency logic.""" @@ -659,7 +949,14 @@ class HonchoMemoryProvider(MemoryProvider): try: if tool_name == "honcho_profile": - card = self._manager.get_peer_card(self._session_key) + peer = args.get("peer", "user") + card_update = args.get("card") + if card_update: + result = self._manager.set_peer_card(self._session_key, card_update, peer=peer) + if result is None: + return tool_error("Failed to update peer card.") + return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result}) + card = self._manager.get_peer_card(self._session_key, peer=peer) if not card: return json.dumps({"result": "No profile facts available yet."}) return json.dumps({"result": card}) @@ -669,30 +966,64 @@ class HonchoMemoryProvider(MemoryProvider): if not query: return tool_error("Missing required parameter: query") max_tokens = min(int(args.get("max_tokens", 800)), 2000) + peer = args.get("peer", "user") result = self._manager.search_context( - self._session_key, query, max_tokens=max_tokens + self._session_key, query, max_tokens=max_tokens, peer=peer ) if not result: return json.dumps({"result": "No relevant context found."}) return json.dumps({"result": result}) - elif tool_name == "honcho_context": + elif tool_name == "honcho_reasoning": query = args.get("query", "") if not query: return tool_error("Missing required parameter: query") peer = args.get("peer", "user") + reasoning_level = args.get("reasoning_level") result = self._manager.dialectic_query( - self._session_key, query, peer=peer + self._session_key, query, + reasoning_level=reasoning_level, + peer=peer, ) + # Update cadence tracker so auto-injection respects the gap after an explicit call + self._last_dialectic_turn = self._turn_count return json.dumps({"result": result or "No result from Honcho."}) + elif tool_name == "honcho_context": + peer = args.get("peer", "user") + ctx = self._manager.get_session_context(self._session_key, peer=peer) + if not ctx: + return json.dumps({"result": "No context available yet."}) + parts = [] + if ctx.get("summary"): + parts.append(f"## Summary\n{ctx['summary']}") + if ctx.get("representation"): + parts.append(f"## Representation\n{ctx['representation']}") + if ctx.get("card"): + parts.append(f"## Card\n{ctx['card']}") + if ctx.get("recent_messages"): + msgs = ctx["recent_messages"] + msg_str = "\n".join( + f" [{m['role']}] {m['content'][:200]}" + for m in msgs[-5:] # last 5 for brevity + ) + parts.append(f"## Recent messages\n{msg_str}") + return json.dumps({"result": "\n\n".join(parts) or "No context available."}) + elif tool_name == "honcho_conclude": + delete_id = args.get("delete_id") + peer = args.get("peer", "user") + if delete_id: + ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer) + if ok: + return json.dumps({"result": f"Conclusion {delete_id} deleted."}) + return tool_error(f"Failed to delete conclusion {delete_id}.") conclusion = args.get("conclusion", "") if not conclusion: - return tool_error("Missing required parameter: conclusion") - ok = self._manager.create_conclusion(self._session_key, conclusion) + return tool_error("Missing required parameter: conclusion or delete_id") + ok = self._manager.create_conclusion(self._session_key, conclusion, peer=peer) if ok: - return json.dumps({"result": f"Conclusion saved: {conclusion}"}) + return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"}) return tool_error("Failed to save conclusion.") return tool_error(f"Unknown tool: {tool_name}") diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index dff4b386a..536d34002 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -440,11 +440,43 @@ def cmd_setup(args) -> None: if new_recall in ("hybrid", "context", "tools"): hermes_host["recallMode"] = new_recall - # --- 7. Session strategy --- - current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory") + # --- 7. Context token budget --- + current_ctx_tokens = hermes_host.get("contextTokens") or cfg.get("contextTokens") + current_display = str(current_ctx_tokens) if current_ctx_tokens else "uncapped" + print("\n Context injection per turn (hybrid/context recall modes only):") + print(" uncapped -- no limit (default)") + print(" N -- token limit per turn (e.g. 1200)") + new_ctx_tokens = _prompt("Context tokens", default=current_display) + if new_ctx_tokens.strip().lower() in ("none", "uncapped", "no limit"): + hermes_host.pop("contextTokens", None) + elif new_ctx_tokens.strip() == "": + pass # keep current + else: + try: + val = int(new_ctx_tokens) + if val >= 0: + hermes_host["contextTokens"] = val + except (ValueError, TypeError): + pass # keep current + + # --- 7b. Dialectic cadence --- + current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3") + print("\n Dialectic cadence:") + print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") + print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.") + new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) + try: + val = int(new_dialectic) + if val >= 1: + hermes_host["dialecticCadence"] = val + except (ValueError, TypeError): + hermes_host["dialecticCadence"] = 3 + + # --- 8. Session strategy --- + current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") print("\n Session strategy:") - print(" per-directory -- one session per working directory (default)") - print(" per-session -- new Honcho session each run") + print(" per-session -- each run starts clean, Honcho injects context automatically") + print(" per-directory -- reuses session per dir, prior context auto-injected each run") print(" per-repo -- one session per git repository") print(" global -- single session across all directories") new_strat = _prompt("Session strategy", default=current_strat) @@ -490,10 +522,11 @@ def cmd_setup(args) -> None: print(f" Recall: {hcfg.recall_mode}") print(f" Sessions: {hcfg.session_strategy}") print("\n Honcho tools available in chat:") - print(" honcho_context -- ask Honcho about the user (LLM-synthesized)") - print(" honcho_search -- semantic search over history (no LLM)") - print(" honcho_profile -- peer card, key facts (no LLM)") - print(" honcho_conclude -- persist a user fact to memory (no LLM)") + print(" honcho_context -- session context: summary, representation, card, messages") + print(" honcho_search -- semantic search over history") + print(" honcho_profile -- peer card, key facts") + print(" honcho_reasoning -- ask Honcho a question, synthesized answer") + print(" honcho_conclude -- persist a user fact to memory") print("\n Other commands:") print(" hermes honcho status -- show full config") print(" hermes honcho mode -- change recall/observation mode") @@ -585,13 +618,26 @@ def cmd_status(args) -> None: print(f" Enabled: {hcfg.enabled}") print(f" API key: {masked}") print(f" Workspace: {hcfg.workspace_id}") - print(f" Config path: {active_path}") + + # Config paths — show where config was read from and where writes go + global_path = Path.home() / ".honcho" / "config.json" + print(f" Config: {active_path}") if write_path != active_path: - print(f" Write path: {write_path} (instance-local)") + print(f" Write to: {write_path} (profile-local)") + if active_path == global_path: + print(f" Fallback: (none — using global ~/.honcho/config.json)") + elif global_path.exists(): + print(f" Fallback: {global_path} (exists, cross-app interop)") + print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") + print(f" Session strat: {hcfg.session_strategy}") print(f" Recall mode: {hcfg.recall_mode}") + print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens") + raw = getattr(hcfg, "raw", None) or {} + dialectic_cadence = raw.get("dialecticCadence") or 3 + print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}") print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})") print(f" Write freq: {hcfg.write_frequency}") @@ -599,8 +645,8 @@ def cmd_status(args) -> None: print("\n Connection... ", end="", flush=True) try: client = get_honcho_client(hcfg) - print("OK") _show_peer_cards(hcfg, client) + print("OK") except Exception as e: print(f"FAILED ({e})\n") else: @@ -824,6 +870,41 @@ def cmd_mode(args) -> None: print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n") +def cmd_strategy(args) -> None: + """Show or set the session strategy.""" + STRATEGIES = { + "per-session": "each run starts clean, Honcho injects context automatically", + "per-directory": "reuses session per dir, prior context auto-injected each run", + "per-repo": "one session per git repository", + "global": "single session across all directories", + } + cfg = _read_config() + strat_arg = getattr(args, "strategy", None) + + if strat_arg is None: + current = ( + (cfg.get("hosts") or {}).get(_host_key(), {}).get("sessionStrategy") + or cfg.get("sessionStrategy") + or "per-session" + ) + print("\nHoncho session strategy\n" + "─" * 40) + for s, desc in STRATEGIES.items(): + marker = " <-" if s == current else "" + print(f" {s:<15} {desc}{marker}") + print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n") + return + + if strat_arg not in STRATEGIES: + print(f" Invalid strategy '{strat_arg}'. Options: {', '.join(STRATEGIES)}\n") + return + + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + cfg.setdefault("hosts", {}).setdefault(host, {})["sessionStrategy"] = strat_arg + _write_config(cfg) + print(f" {label}Session strategy -> {strat_arg} ({STRATEGIES[strat_arg]})\n") + + def cmd_tokens(args) -> None: """Show or set token budget settings.""" cfg = _read_config() @@ -1143,10 +1224,11 @@ def cmd_migrate(args) -> None: print(" automatically. Files become the seed, not the live store.") print() print(" Honcho tools (available to the agent during conversation)") - print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)") - print(" honcho_search — semantic search over stored context (no LLM)") - print(" honcho_profile — fast peer card snapshot (no LLM)") - print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)") + print(" honcho_context — session context: summary, representation, card, messages") + print(" honcho_search — semantic search over stored context") + print(" honcho_profile — fast peer card snapshot") + print(" honcho_reasoning — ask Honcho a question, synthesized answer") + print(" honcho_conclude — write a conclusion/fact back to memory") print() print(" Session naming") print(" OpenClaw: no persistent session concept — files are global.") @@ -1197,6 +1279,8 @@ def honcho_command(args) -> None: cmd_peer(args) elif sub == "mode": cmd_mode(args) + elif sub == "strategy": + cmd_strategy(args) elif sub == "tokens": cmd_tokens(args) elif sub == "identity": @@ -1211,7 +1295,7 @@ def honcho_command(args) -> None: cmd_sync(args) else: print(f" Unknown honcho command: {sub}") - print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n") + print(" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n") def register_cli(subparser) -> None: @@ -1270,6 +1354,15 @@ def register_cli(subparser) -> None: help="Recall mode to set (hybrid/context/tools). Omit to show current.", ) + strategy_parser = subs.add_parser( + "strategy", help="Show or set session strategy (per-session/per-directory/per-repo/global)", + ) + strategy_parser.add_argument( + "strategy", nargs="?", metavar="STRATEGY", + choices=("per-session", "per-directory", "per-repo", "global"), + help="Session strategy to set. Omit to show current.", + ) + tokens_parser = subs.add_parser( "tokens", help="Show or set token budget for context and dialectic", ) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 22cd393a2..2474d3a2b 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -58,7 +58,8 @@ def resolve_config_path() -> Path: Resolution order: 1. $HERMES_HOME/honcho.json (profile-local, if it exists) - 2. ~/.honcho/config.json (global, cross-app interop) + 2. ~/.hermes/honcho.json (default profile — shared host blocks live here) + 3. ~/.honcho/config.json (global, cross-app interop) Returns the global path if none exist (for first-time setup writes). """ @@ -66,6 +67,11 @@ def resolve_config_path() -> Path: if local_path.exists(): return local_path + # Default profile's config — host blocks accumulate here via setup/clone + default_path = Path.home() / ".hermes" / "honcho.json" + if default_path != local_path and default_path.exists(): + return default_path + return GLOBAL_CONFIG_PATH @@ -88,6 +94,68 @@ def _resolve_bool(host_val, root_val, *, default: bool) -> bool: return default +def _parse_context_tokens(host_val, root_val) -> int | None: + """Parse contextTokens: host wins, then root, then None (uncapped).""" + for val in (host_val, root_val): + if val is not None: + try: + return int(val) + except (ValueError, TypeError): + pass + return None + + +def _parse_dialectic_depth(host_val, root_val) -> int: + """Parse dialecticDepth: host wins, then root, then 1. Clamped to 1-3.""" + for val in (host_val, root_val): + if val is not None: + try: + return max(1, min(int(val), 3)) + except (ValueError, TypeError): + pass + return 1 + + +_VALID_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max") + + +def _parse_dialectic_depth_levels(host_val, root_val, depth: int) -> list[str] | None: + """Parse dialecticDepthLevels: optional array of reasoning levels per pass. + + Returns None when not configured (use proportional defaults). + When configured, validates each level and truncates/pads to match depth. + """ + for val in (host_val, root_val): + if val is not None and isinstance(val, list): + levels = [ + lvl if lvl in _VALID_REASONING_LEVELS else "low" + for lvl in val[:depth] + ] + # Pad with "low" if array is shorter than depth + while len(levels) < depth: + levels.append("low") + return levels + return None + + +def _resolve_optional_float(*values: Any) -> float | None: + """Return the first non-empty value coerced to a positive float.""" + for value in values: + if value is None: + continue + if isinstance(value, str): + value = value.strip() + if not value: + continue + try: + parsed = float(value) + except (TypeError, ValueError): + continue + if parsed > 0: + return parsed + return None + + _VALID_OBSERVATION_MODES = {"unified", "directional"} _OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"} @@ -153,6 +221,8 @@ class HonchoClientConfig: environment: str = "production" # Optional base URL for self-hosted Honcho (overrides environment mapping) base_url: str | None = None + # Optional request timeout in seconds for Honcho SDK HTTP calls + timeout: float | None = None # Identity peer_name: str | None = None ai_peer: str = "hermes" @@ -162,17 +232,25 @@ class HonchoClientConfig: # Write frequency: "async" (background thread), "turn" (sync per turn), # "session" (flush on session end), or int (every N turns) write_frequency: str | int = "async" - # Prefetch budget + # Prefetch budget (None = no cap; set to an integer to bound auto-injected context) context_tokens: int | None = None # Dialectic (peer.chat) settings # reasoning_level: "minimal" | "low" | "medium" | "high" | "max" dialectic_reasoning_level: str = "low" - # dynamic: auto-bump reasoning level based on query length - # true — low->medium (120+ chars), low->high (400+ chars), capped at "high" - # false — always use dialecticReasoningLevel as-is + # When true, the model can override reasoning_level per-call via the + # honcho_reasoning tool param (agentic). When false, always uses + # dialecticReasoningLevel and ignores model-provided overrides. dialectic_dynamic: bool = True # Max chars of dialectic result to inject into Hermes system prompt dialectic_max_chars: int = 600 + # Dialectic depth: how many .chat() calls per dialectic cycle (1-3). + # Depth 1: single call. Depth 2: self-audit + targeted synthesis. + # Depth 3: self-audit + synthesis + reconciliation. + dialectic_depth: int = 1 + # Optional per-pass reasoning level override. Array of reasoning levels + # matching dialectic_depth length. When None, uses proportional defaults + # derived from dialectic_reasoning_level. + dialectic_depth_levels: list[str] | None = None # Honcho API limits — configurable for self-hosted instances # Max chars per message sent via add_messages() (Honcho cloud: 25000) message_max_chars: int = 25000 @@ -183,10 +261,8 @@ class HonchoClientConfig: # "context" — auto-injected context only, Honcho tools removed # "tools" — Honcho tools only, no auto-injected context recall_mode: str = "hybrid" - # When True and recallMode is "tools", create the Honcho session eagerly - # during initialize() instead of deferring to the first tool call. - # This ensures sync_turn() can write from the very first turn. - # Does NOT enable automatic context injection — only changes init timing. + # Eager init in tools mode — when true, initializes session during + # initialize() instead of deferring to first tool call init_on_session_start: bool = False # Observation mode: legacy string shorthand ("directional" or "unified"). # Kept for backward compat; granular per-peer booleans below are preferred. @@ -218,12 +294,14 @@ class HonchoClientConfig: resolved_host = host or resolve_active_host() api_key = os.environ.get("HONCHO_API_KEY") base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None + timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")) return cls( host=resolved_host, workspace_id=workspace_id, api_key=api_key, environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), base_url=base_url, + timeout=timeout, ai_peer=resolved_host, enabled=bool(api_key or base_url), ) @@ -284,6 +362,11 @@ class HonchoClientConfig: or os.environ.get("HONCHO_BASE_URL", "").strip() or None ) + timeout = _resolve_optional_float( + raw.get("timeout"), + raw.get("requestTimeout"), + os.environ.get("HONCHO_TIMEOUT"), + ) # Auto-enable when API key or base_url is present (unless explicitly disabled) # Host-level enabled wins, then root-level, then auto-enable if key/url exists. @@ -329,12 +412,16 @@ class HonchoClientConfig: api_key=api_key, environment=environment, base_url=base_url, + timeout=timeout, peer_name=host_block.get("peerName") or raw.get("peerName"), ai_peer=ai_peer, enabled=enabled, save_messages=save_messages, write_frequency=write_frequency, - context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"), + context_tokens=_parse_context_tokens( + host_block.get("contextTokens"), + raw.get("contextTokens"), + ), dialectic_reasoning_level=( host_block.get("dialecticReasoningLevel") or raw.get("dialecticReasoningLevel") @@ -350,6 +437,15 @@ class HonchoClientConfig: or raw.get("dialecticMaxChars") or 600 ), + dialectic_depth=_parse_dialectic_depth( + host_block.get("dialecticDepth"), + raw.get("dialecticDepth"), + ), + dialectic_depth_levels=_parse_dialectic_depth_levels( + host_block.get("dialecticDepthLevels"), + raw.get("dialecticDepthLevels"), + depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")), + ), message_max_chars=int( host_block.get("messageMaxChars") or raw.get("messageMaxChars") @@ -416,16 +512,18 @@ class HonchoClientConfig: cwd: str | None = None, session_title: str | None = None, session_id: str | None = None, + gateway_session_key: str | None = None, ) -> str | None: """Resolve Honcho session name. Resolution order: 1. Manual directory override from sessions map 2. Hermes session title (from /title command) - 3. per-session strategy — Hermes session_id ({timestamp}_{hex}) - 4. per-repo strategy — git repo root directory name - 5. per-directory strategy — directory basename - 6. global strategy — workspace name + 3. Gateway session key (stable per-chat identifier from gateway platforms) + 4. per-session strategy — Hermes session_id ({timestamp}_{hex}) + 5. per-repo strategy — git repo root directory name + 6. per-directory strategy — directory basename + 7. global strategy — workspace name """ import re @@ -439,12 +537,22 @@ class HonchoClientConfig: # /title mid-session remap if session_title: - sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', session_title).strip('-') + sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', session_title).strip('-') if sanitized: if self.session_peer_prefix and self.peer_name: return f"{self.peer_name}-{sanitized}" return sanitized + # Gateway session key: stable per-chat identifier passed by the gateway + # (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens + # for Honcho session ID compatibility. This takes priority over strategy- + # based resolution because gateway platforms need per-chat isolation that + # cwd-based strategies cannot provide. + if gateway_session_key: + sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-') + if sanitized: + return sanitized + # per-session: inherit Hermes session_id (new Honcho session each run) if self.session_strategy == "per-session" and session_id: if self.session_peer_prefix and self.peer_name: @@ -506,13 +614,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: # mapping, enabling remote self-hosted Honcho deployments without # requiring the server to live on localhost. resolved_base_url = config.base_url - if not resolved_base_url: + resolved_timeout = config.timeout + if not resolved_base_url or resolved_timeout is None: try: from hermes_cli.config import load_config hermes_cfg = load_config() honcho_cfg = hermes_cfg.get("honcho", {}) if isinstance(honcho_cfg, dict): - resolved_base_url = honcho_cfg.get("base_url", "").strip() or None + if not resolved_base_url: + resolved_base_url = honcho_cfg.get("base_url", "").strip() or None + if resolved_timeout is None: + resolved_timeout = _resolve_optional_float( + honcho_cfg.get("timeout"), + honcho_cfg.get("request_timeout"), + ) except Exception: pass @@ -547,6 +662,8 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: } if resolved_base_url: kwargs["base_url"] = resolved_base_url + if resolved_timeout is not None: + kwargs["timeout"] = resolved_timeout _honcho_client = Honcho(**kwargs) diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 2cd4c5bd2..fd91ee3b3 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -486,36 +486,9 @@ class HonchoSessionManager: _REASONING_LEVELS = ("minimal", "low", "medium", "high", "max") - def _dynamic_reasoning_level(self, query: str) -> str: - """ - Pick a reasoning level for a dialectic query. - - When dialecticDynamic is true (default), auto-bumps based on query - length so Honcho applies more inference where it matters: - - < 120 chars -> configured default (typically "low") - 120-400 chars -> +1 level above default (cap at "high") - > 400 chars -> +2 levels above default (cap at "high") - - "max" is never selected automatically -- reserve it for explicit config. - - When dialecticDynamic is false, always returns the configured level. - """ - if not self._dialectic_dynamic: - return self._dialectic_reasoning_level - - levels = self._REASONING_LEVELS - default_idx = levels.index(self._dialectic_reasoning_level) if self._dialectic_reasoning_level in levels else 1 - n = len(query) - if n < 120: - bump = 0 - elif n < 400: - bump = 1 - else: - bump = 2 - # Cap at "high" (index 3) for auto-selection - idx = min(default_idx + bump, 3) - return levels[idx] + def _default_reasoning_level(self) -> str: + """Return the configured default reasoning level.""" + return self._dialectic_reasoning_level def dialectic_query( self, session_key: str, query: str, @@ -532,8 +505,9 @@ class HonchoSessionManager: Args: session_key: The session key to query against. query: Natural language question. - reasoning_level: Override the config default. If None, uses - _dynamic_reasoning_level(query). + reasoning_level: Override the configured default (dialecticReasoningLevel). + Only honored when dialecticDynamic is true. + If None or dialecticDynamic is false, uses the configured default. peer: Which peer to query — "user" (default) or "ai". Returns: @@ -543,29 +517,34 @@ class HonchoSessionManager: if not session: return "" + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id is None: + return "" + # Guard: truncate query to Honcho's dialectic input limit if len(query) > self._dialectic_max_input_chars: query = query[:self._dialectic_max_input_chars].rsplit(" ", 1)[0] - level = reasoning_level or self._dynamic_reasoning_level(query) + if self._dialectic_dynamic and reasoning_level: + level = reasoning_level + else: + level = self._default_reasoning_level() try: if self._ai_observe_others: - # AI peer can observe user — use cross-observation routing - if peer == "ai": - ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) + # AI peer can observe other peers — use assistant as observer. + ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) + if target_peer_id == session.assistant_peer_id: result = ai_peer_obj.chat(query, reasoning_level=level) or "" else: - ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id) result = ai_peer_obj.chat( query, - target=session.user_peer_id, + target=target_peer_id, reasoning_level=level, ) or "" else: - # AI can't observe others — each peer queries self - peer_id = session.assistant_peer_id if peer == "ai" else session.user_peer_id - target_peer = self._get_or_create_peer(peer_id) + # Without cross-observation, each peer queries its own context. + target_peer = self._get_or_create_peer(target_peer_id) result = target_peer.chat(query, reasoning_level=level) or "" # Apply Hermes-side char cap before caching @@ -647,10 +626,11 @@ class HonchoSessionManager: """ Pre-fetch user and AI peer context from Honcho. - Fetches peer_representation and peer_card for both peers. search_query - is intentionally omitted — it would only affect additional excerpts - that this code does not consume, and passing the raw message exposes - conversation content in server access logs. + Fetches peer_representation and peer_card for both peers, plus the + session summary when available. search_query is intentionally omitted + — it would only affect additional excerpts that this code does not + consume, and passing the raw message exposes conversation content in + server access logs. Args: session_key: The session key to get context for. @@ -658,15 +638,29 @@ class HonchoSessionManager: Returns: Dictionary with 'representation', 'card', 'ai_representation', - and 'ai_card' keys. + 'ai_card', and optionally 'summary' keys. """ session = self._cache.get(session_key) if not session: return {} result: dict[str, str] = {} + + # Session summary — provides session-scoped context. + # Fresh sessions (per-session cold start, or first-ever per-directory) + # return null summary — the guard below handles that gracefully. + # Per-directory returning sessions get their accumulated summary. try: - user_ctx = self._fetch_peer_context(session.user_peer_id) + honcho_session = self._sessions_cache.get(session.honcho_session_id) + if honcho_session: + ctx = honcho_session.context(summary=True) + if ctx.summary and getattr(ctx.summary, "content", None): + result["summary"] = ctx.summary.content + except Exception as e: + logger.debug("Failed to fetch session summary from Honcho: %s", e) + + try: + user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id) result["representation"] = user_ctx["representation"] result["card"] = "\n".join(user_ctx["card"]) except Exception as e: @@ -674,7 +668,7 @@ class HonchoSessionManager: # Also fetch AI peer's own representation so Hermes knows itself. try: - ai_ctx = self._fetch_peer_context(session.assistant_peer_id) + ai_ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id) result["ai_representation"] = ai_ctx["representation"] result["ai_card"] = "\n".join(ai_ctx["card"]) except Exception as e: @@ -862,7 +856,7 @@ class HonchoSessionManager: return [str(item) for item in card if item] return [str(card)] - def _fetch_peer_card(self, peer_id: str) -> list[str]: + def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[str]: """Fetch a peer card directly from the peer object. This avoids relying on session.context(), which can return an empty @@ -872,22 +866,33 @@ class HonchoSessionManager: peer = self._get_or_create_peer(peer_id) getter = getattr(peer, "get_card", None) if callable(getter): - return self._normalize_card(getter()) + return self._normalize_card(getter(target=target) if target is not None else getter()) legacy_getter = getattr(peer, "card", None) if callable(legacy_getter): - return self._normalize_card(legacy_getter()) + return self._normalize_card(legacy_getter(target=target) if target is not None else legacy_getter()) return [] - def _fetch_peer_context(self, peer_id: str, search_query: str | None = None) -> dict[str, Any]: + def _fetch_peer_context( + self, + peer_id: str, + search_query: str | None = None, + *, + target: str | None = None, + ) -> dict[str, Any]: """Fetch representation + peer card directly from a peer object.""" peer = self._get_or_create_peer(peer_id) representation = "" card: list[str] = [] try: - ctx = peer.context(search_query=search_query) if search_query else peer.context() + context_kwargs: dict[str, Any] = {} + if target is not None: + context_kwargs["target"] = target + if search_query is not None: + context_kwargs["search_query"] = search_query + ctx = peer.context(**context_kwargs) if context_kwargs else peer.context() representation = ( getattr(ctx, "representation", None) or getattr(ctx, "peer_representation", None) @@ -899,24 +904,111 @@ class HonchoSessionManager: if not representation: try: - representation = peer.representation() or "" + representation = ( + peer.representation(target=target) if target is not None else peer.representation() + ) or "" except Exception as e: logger.debug("Direct peer.representation() failed for '%s': %s", peer_id, e) if not card: try: - card = self._fetch_peer_card(peer_id) + card = self._fetch_peer_card(peer_id, target=target) except Exception as e: logger.debug("Direct peer card fetch failed for '%s': %s", peer_id, e) return {"representation": representation, "card": card} - def get_peer_card(self, session_key: str) -> list[str]: + def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]: + """Fetch full session context from Honcho including summary. + + Uses the session-level context() API which returns summary, + peer_representation, peer_card, and messages. """ - Fetch the user peer's card — a curated list of key facts. + session = self._cache.get(session_key) + if not session: + return {} + + honcho_session = self._sessions_cache.get(session.honcho_session_id) + if not honcho_session: + # Fall back to peer-level context, respecting the requested peer + peer_id = self._resolve_peer_id(session, peer) + if peer_id is None: + peer_id = session.user_peer_id + return self._fetch_peer_context(peer_id, target=peer_id) + + try: + peer_id = self._resolve_peer_id(session, peer) + ctx = honcho_session.context( + summary=True, + peer_target=peer_id, + peer_perspective=session.user_peer_id if peer == "user" else session.assistant_peer_id, + ) + + result: dict[str, Any] = {} + + # Summary + if ctx.summary: + result["summary"] = ctx.summary.content + + # Peer representation and card + if ctx.peer_representation: + result["representation"] = ctx.peer_representation + if ctx.peer_card: + result["card"] = "\n".join(ctx.peer_card) + + # Messages (last N for context) + if ctx.messages: + recent = ctx.messages[-10:] # last 10 messages + result["recent_messages"] = [ + {"role": getattr(m, "peer_id", "unknown"), "content": (m.content or "")[:500]} + for m in recent + ] + + return result + except Exception as e: + logger.debug("Session context fetch failed: %s", e) + return {} + + def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str: + """Resolve a peer alias or explicit peer ID to a concrete Honcho peer ID. + + Always returns a non-empty string: either a known peer ID or a + sanitized version of the caller-supplied alias/ID. + """ + candidate = (peer or "user").strip() + if not candidate: + return session.user_peer_id + + normalized = self._sanitize_id(candidate) + if normalized == self._sanitize_id("user"): + return session.user_peer_id + if normalized == self._sanitize_id("ai"): + return session.assistant_peer_id + + return normalized + + def _resolve_observer_target( + self, + session: HonchoSession, + peer: str | None, + ) -> tuple[str, str | None]: + """Resolve observer and target peer IDs for context/search/profile queries.""" + target_peer_id = self._resolve_peer_id(session, peer) + + if target_peer_id == session.assistant_peer_id: + return session.assistant_peer_id, session.assistant_peer_id + + if self._ai_observe_others: + return session.assistant_peer_id, target_peer_id + + return target_peer_id, None + + def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]: + """ + Fetch a peer card — a curated list of key facts. Fast, no LLM reasoning. Returns raw structured facts Honcho has - inferred about the user (name, role, preferences, patterns). + inferred about the target peer (name, role, preferences, patterns). Empty list if unavailable. """ session = self._cache.get(session_key) @@ -924,12 +1016,19 @@ class HonchoSessionManager: return [] try: - return self._fetch_peer_card(session.user_peer_id) + observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) + return self._fetch_peer_card(observer_peer_id, target=target_peer_id) except Exception as e: logger.debug("Failed to fetch peer card from Honcho: %s", e) return [] - def search_context(self, session_key: str, query: str, max_tokens: int = 800) -> str: + def search_context( + self, + session_key: str, + query: str, + max_tokens: int = 800, + peer: str = "user", + ) -> str: """ Semantic search over Honcho session context. @@ -941,6 +1040,7 @@ class HonchoSessionManager: session_key: Session to search against. query: Search query for semantic matching. max_tokens: Token budget for returned content. + peer: Peer alias or explicit peer ID to search about. Returns: Relevant context excerpts as a string, or empty string if none. @@ -950,7 +1050,13 @@ class HonchoSessionManager: return "" try: - ctx = self._fetch_peer_context(session.user_peer_id, search_query=query) + observer_peer_id, target = self._resolve_observer_target(session, peer) + + ctx = self._fetch_peer_context( + observer_peer_id, + search_query=query, + target=target, + ) parts = [] if ctx["representation"]: parts.append(ctx["representation"]) @@ -962,16 +1068,17 @@ class HonchoSessionManager: logger.debug("Honcho search_context failed: %s", e) return "" - def create_conclusion(self, session_key: str, content: str) -> bool: - """Write a conclusion about the user back to Honcho. + def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool: + """Write a conclusion about a target peer back to Honcho. - Conclusions are facts the AI peer observes about the user — - preferences, corrections, clarifications, project context. - They feed into the user's peer card and representation. + Conclusions are facts a peer observes about another peer or itself — + preferences, corrections, clarifications, and project context. + They feed into the target peer's card and representation. Args: session_key: Session to associate the conclusion with. - content: The conclusion text (e.g. "User prefers dark mode"). + content: The conclusion text. + peer: Peer alias or explicit peer ID. "user" is the default alias. Returns: True on success, False on failure. @@ -985,25 +1092,90 @@ class HonchoSessionManager: return False try: - if self._ai_observe_others: - # AI peer creates conclusion about user (cross-observation) + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id is None: + logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key) + return False + + if target_peer_id == session.assistant_peer_id: assistant_peer = self._get_or_create_peer(session.assistant_peer_id) - conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id) + conclusions_scope = assistant_peer.conclusions_of(session.assistant_peer_id) + elif self._ai_observe_others: + assistant_peer = self._get_or_create_peer(session.assistant_peer_id) + conclusions_scope = assistant_peer.conclusions_of(target_peer_id) else: - # AI can't observe others — user peer creates self-conclusion - user_peer = self._get_or_create_peer(session.user_peer_id) - conclusions_scope = user_peer.conclusions_of(session.user_peer_id) + target_peer = self._get_or_create_peer(target_peer_id) + conclusions_scope = target_peer.conclusions_of(target_peer_id) conclusions_scope.create([{ "content": content.strip(), "session_id": session.honcho_session_id, }]) - logger.info("Created conclusion for %s: %s", session_key, content[:80]) + logger.info("Created conclusion about %s for %s: %s", target_peer_id, session_key, content[:80]) return True except Exception as e: logger.error("Failed to create conclusion: %s", e) return False + def delete_conclusion(self, session_key: str, conclusion_id: str, peer: str = "user") -> bool: + """Delete a conclusion by ID. Use only for PII removal. + + Args: + session_key: Session key for peer resolution. + conclusion_id: The conclusion ID to delete. + peer: Peer alias or explicit peer ID. + + Returns: + True on success, False on failure. + """ + session = self._cache.get(session_key) + if not session: + return False + try: + target_peer_id = self._resolve_peer_id(session, peer) + if target_peer_id == session.assistant_peer_id: + observer = self._get_or_create_peer(session.assistant_peer_id) + scope = observer.conclusions_of(session.assistant_peer_id) + elif self._ai_observe_others: + observer = self._get_or_create_peer(session.assistant_peer_id) + scope = observer.conclusions_of(target_peer_id) + else: + target_peer = self._get_or_create_peer(target_peer_id) + scope = target_peer.conclusions_of(target_peer_id) + scope.delete(conclusion_id) + logger.info("Deleted conclusion %s for %s", conclusion_id, session_key) + return True + except Exception as e: + logger.error("Failed to delete conclusion %s: %s", conclusion_id, e) + return False + + def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None: + """Update a peer's card. + + Args: + session_key: Session key for peer resolution. + card: New peer card as list of fact strings. + peer: Peer alias or explicit peer ID. + + Returns: + Updated card on success, None on failure. + """ + session = self._cache.get(session_key) + if not session: + return None + try: + peer_id = self._resolve_peer_id(session, peer) + if peer_id is None: + logger.warning("Could not resolve peer '%s' for set_peer_card in session '%s'", peer, session_key) + return None + peer_obj = self._get_or_create_peer(peer_id) + result = peer_obj.set_card(card) + logger.info("Updated peer card for %s (%d facts)", peer_id, len(card)) + return result + except Exception as e: + logger.error("Failed to set peer card: %s", e) + return None + def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool: """ Seed the AI peer's Honcho representation from text content. @@ -1061,7 +1233,7 @@ class HonchoSessionManager: return {"representation": "", "card": ""} try: - ctx = self._fetch_peer_context(session.assistant_peer_id) + ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id) return { "representation": ctx["representation"] or "", "card": "\n".join(ctx["card"]), diff --git a/run_agent.py b/run_agent.py index d332fb6eb..47473eb51 100644 --- a/run_agent.py +++ b/run_agent.py @@ -75,7 +75,7 @@ from tools.browser_tool import cleanup_browser from hermes_constants import OPENROUTER_BASE_URL # Agent internals extracted to agent/ package for modularity -from agent.memory_manager import build_memory_context_block +from agent.memory_manager import build_memory_context_block, sanitize_context from agent.retry_utils import jittered_backoff from agent.error_classifier import classify_api_error, FailoverReason from agent.prompt_builder import ( @@ -602,6 +602,7 @@ class AIAgent: prefill_messages: List[Dict[str, Any]] = None, platform: str = None, user_id: str = None, + gateway_session_key: str = None, skip_context_files: bool = False, skip_memory: bool = False, session_db=None, @@ -667,6 +668,7 @@ class AIAgent: self.ephemeral_system_prompt = ephemeral_system_prompt self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. self._user_id = user_id # Platform user identifier (gateway sessions) + self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123) # Pluggable print function — CLI replaces this with _cprint so that # raw ANSI status lines are routed through prompt_toolkit's renderer # instead of going directly to stdout where patch_stdout's StdoutProxy @@ -1292,6 +1294,9 @@ class AIAgent: # Thread gateway user identity for per-user memory scoping if self._user_id: _init_kwargs["user_id"] = self._user_id + # Thread gateway session key for stable per-chat Honcho session isolation + if self._gateway_session_key: + _init_kwargs["gateway_session_key"] = self._gateway_session_key # Profile identity for per-profile provider scoping try: from hermes_cli.profiles import get_active_profile_name @@ -8149,6 +8154,16 @@ class AIAgent: if isinstance(persist_user_message, str): persist_user_message = _sanitize_surrogates(persist_user_message) + # Strip leaked blocks from user input. When Honcho's + # saveMessages persists a turn that included injected context, the block + # can reappear in the next turn's user message via message history. + # Stripping here prevents stale memory tags from leaking into the + # conversation and being visible to the user or the model as user text. + if isinstance(user_message, str): + user_message = sanitize_context(user_message) + if isinstance(persist_user_message, str): + persist_user_message = sanitize_context(persist_user_message) + # Store stream callback for _interruptible_api_call to pick up self._stream_callback = stream_callback self._persist_user_message_idx = None @@ -8428,6 +8443,16 @@ class AIAgent: self._interrupt_message = None self._interrupt_thread_signal_pending = False + # Notify memory providers of the new turn so cadence tracking works. + # Must happen BEFORE prefetch_all() so providers know which turn it is + # and can gate context/dialectic refresh via contextCadence/dialecticCadence. + if self._memory_manager: + try: + _turn_msg = original_user_message if isinstance(original_user_message, str) else "" + self._memory_manager.on_turn_start(self._user_turn_count, _turn_msg) + except Exception: + pass + # External memory provider: prefetch once before the tool loop. # Reuse the cached result on every iteration to avoid re-calling # prefetch_all() on each tool call (10 tool calls = 10x latency + cost). diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index db2a70c2f..9301960b7 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -939,3 +939,74 @@ class TestOnMemoryWriteBridge: mgr.on_memory_write("add", "user", "test") # Good provider still received the call despite bad provider crashing assert good.memory_writes == [("add", "user", "test")] + + +class TestHonchoCadenceTracking: + """Verify Honcho provider cadence gating depends on on_turn_start(). + + Bug: _turn_count was never updated because on_turn_start() was not called + from run_conversation(). This meant cadence checks always passed (every + turn fired both context refresh and dialectic). Fixed by calling + on_turn_start(self._user_turn_count, msg) before prefetch_all(). + """ + + def test_turn_count_updates_on_turn_start(self): + """on_turn_start sets _turn_count, enabling cadence math.""" + from plugins.memory.honcho import HonchoMemoryProvider + p = HonchoMemoryProvider() + assert p._turn_count == 0 + p.on_turn_start(1, "hello") + assert p._turn_count == 1 + p.on_turn_start(5, "world") + assert p._turn_count == 5 + + def test_queue_prefetch_respects_dialectic_cadence(self): + """With dialecticCadence=3, dialectic should skip turns 2 and 3.""" + from plugins.memory.honcho import HonchoMemoryProvider + p = HonchoMemoryProvider() + p._dialectic_cadence = 3 + p._recall_mode = "context" + p._session_key = "test-session" + # Simulate a manager that records prefetch calls + class FakeManager: + def prefetch_context(self, key, query=None): + pass + def prefetch_dialectic(self, key, query): + pass + + p._manager = FakeManager() + + # Simulate turn 1: last_dialectic_turn = -999, so (1 - (-999)) >= 3 -> fires + p.on_turn_start(1, "turn 1") + p._last_dialectic_turn = 1 # simulate it fired + p._last_context_turn = 1 + + # Simulate turn 2: (2 - 1) = 1 < 3 -> should NOT fire dialectic + p.on_turn_start(2, "turn 2") + assert (p._turn_count - p._last_dialectic_turn) < p._dialectic_cadence + + # Simulate turn 3: (3 - 1) = 2 < 3 -> should NOT fire dialectic + p.on_turn_start(3, "turn 3") + assert (p._turn_count - p._last_dialectic_turn) < p._dialectic_cadence + + # Simulate turn 4: (4 - 1) = 3 >= 3 -> should fire dialectic + p.on_turn_start(4, "turn 4") + assert (p._turn_count - p._last_dialectic_turn) >= p._dialectic_cadence + + def test_injection_frequency_first_turn_with_1indexed(self): + """injection_frequency='first-turn' must inject on turn 1 (1-indexed).""" + from plugins.memory.honcho import HonchoMemoryProvider + p = HonchoMemoryProvider() + p._injection_frequency = "first-turn" + + # Turn 1 should inject (not skip) + p.on_turn_start(1, "first message") + assert p._turn_count == 1 + # The guard is `_turn_count > 1`, so turn 1 passes through + should_skip = p._injection_frequency == "first-turn" and p._turn_count > 1 + assert not should_skip, "First turn (turn 1) should NOT be skipped" + + # Turn 2 should skip + p.on_turn_start(2, "second message") + should_skip = p._injection_frequency == "first-turn" and p._turn_count > 1 + assert should_skip, "Second turn (turn 2) SHOULD be skipped" diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py new file mode 100644 index 000000000..006d687dc --- /dev/null +++ b/tests/honcho_plugin/test_cli.py @@ -0,0 +1,56 @@ +"""Tests for plugins/memory/honcho/cli.py.""" + +from types import SimpleNamespace + + +class TestCmdStatus: + def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path): + import plugins.memory.honcho.cli as honcho_cli + + cfg_path = tmp_path / "honcho.json" + cfg_path.write_text("{}") + + class FakeConfig: + enabled = True + api_key = "root-key" + workspace_id = "hermes" + host = "hermes" + base_url = None + ai_peer = "hermes" + peer_name = "eri" + recall_mode = "hybrid" + user_observe_me = True + user_observe_others = False + ai_observe_me = False + ai_observe_others = True + write_frequency = "async" + session_strategy = "per-session" + context_tokens = 800 + + def resolve_session_name(self): + return "hermes" + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: {"apiKey": "***"}) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: FakeConfig(), + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.get_honcho_client", + lambda cfg: object(), + ) + + def _boom(hcfg, client): + raise RuntimeError("Invalid API key") + + monkeypatch.setattr(honcho_cli, "_show_peer_cards", _boom) + monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace()) + + honcho_cli.cmd_status(SimpleNamespace(all=False)) + + out = capsys.readouterr().out + assert "FAILED (Invalid API key)" in out + assert "Connection... OK" not in out \ No newline at end of file diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index cfb89482d..7b6bd46f1 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -1,5 +1,6 @@ """Tests for plugins/memory/honcho/client.py — Honcho client configuration.""" +import importlib.util import json import os from pathlib import Path @@ -25,6 +26,7 @@ class TestHonchoClientConfigDefaults: assert config.workspace_id == "hermes" assert config.api_key is None assert config.environment == "production" + assert config.timeout is None assert config.enabled is False assert config.save_messages is True assert config.session_strategy == "per-directory" @@ -76,6 +78,11 @@ class TestFromEnv: assert config.base_url == "http://localhost:8000" assert config.enabled is True + def test_reads_timeout_from_env(self): + with patch.dict(os.environ, {"HONCHO_TIMEOUT": "90"}, clear=True): + config = HonchoClientConfig.from_env() + assert config.timeout == 90.0 + class TestFromGlobalConfig: def test_missing_config_falls_back_to_env(self, tmp_path): @@ -87,10 +94,10 @@ class TestFromGlobalConfig: assert config.enabled is False assert config.api_key is None - def test_reads_full_config(self, tmp_path): + def test_reads_full_config(self, tmp_path, monkeypatch): config_file = tmp_path / "config.json" config_file.write_text(json.dumps({ - "apiKey": "my-honcho-key", + "apiKey": "***", "workspace": "my-workspace", "environment": "staging", "peerName": "alice", @@ -108,9 +115,11 @@ class TestFromGlobalConfig: } } })) + # Isolate from real ~/.hermes/honcho.json + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.api_key == "my-honcho-key" + assert config.api_key == "***" # Host block workspace overrides root workspace assert config.workspace_id == "override-ws" assert config.ai_peer == "override-ai" @@ -154,10 +163,31 @@ class TestFromGlobalConfig: def test_session_strategy_default_from_global_config(self, tmp_path): """from_global_config with no sessionStrategy should match dataclass default.""" config_file = tmp_path / "config.json" - config_file.write_text(json.dumps({"apiKey": "key"})) + config_file.write_text(json.dumps({"apiKey": "***"})) config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.session_strategy == "per-directory" + def test_context_tokens_default_is_none(self, tmp_path): + """Default context_tokens should be None (uncapped) unless explicitly set.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***"})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens is None + + def test_context_tokens_explicit_sets_cap(self, tmp_path): + """Explicit contextTokens in config sets the cap.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 1200})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens == 1200 + + def test_context_tokens_explicit_overrides_default(self, tmp_path): + """Explicit contextTokens in config should override the default.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 2000})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.context_tokens == 2000 + def test_context_tokens_host_block_wins(self, tmp_path): """Host block contextTokens should override root.""" config_file = tmp_path / "config.json" @@ -232,6 +262,20 @@ class TestFromGlobalConfig: config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.base_url == "http://root:9000" + def test_timeout_from_config_root(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"timeout": 75})) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.timeout == 75.0 + + def test_request_timeout_alias_from_config_root(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"requestTimeout": "82.5"})) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.timeout == 82.5 + class TestResolveSessionName: def test_manual_override(self): @@ -333,13 +377,14 @@ class TestResolveConfigPath: hermes_home.mkdir() local_cfg = hermes_home / "honcho.json" local_cfg.write_text(json.dumps({ - "apiKey": "local-key", + "apiKey": "***", "workspace": "local-ws", })) - with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ + patch.object(Path, "home", return_value=tmp_path): config = HonchoClientConfig.from_global_config() - assert config.api_key == "local-key" + assert config.api_key == "***" assert config.workspace_id == "local-ws" @@ -500,46 +545,115 @@ class TestObservationModeMigration: assert cfg.ai_observe_others is True -class TestInitOnSessionStart: - """Tests for the initOnSessionStart config field.""" +class TestGetHonchoClient: + def teardown_method(self): + reset_honcho_client() - def test_default_is_false(self): + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_passes_timeout_from_config(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + timeout=91.0, + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho: + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 91.0 + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_hermes_config_timeout_override_used_when_config_timeout_missing(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 88}}): + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 88.0 + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_hermes_request_timeout_alias_used(self): + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="test-key", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={"honcho": {"request_timeout": "77.5"}}): + client = get_honcho_client(cfg) + + assert client is fake_honcho + mock_honcho.assert_called_once() + assert mock_honcho.call_args.kwargs["timeout"] == 77.5 + + +class TestResolveSessionNameGatewayKey: + """Regression tests for gateway_session_key priority in resolve_session_name. + + Ensures gateway platforms get stable per-chat Honcho sessions even when + sessionStrategy=per-session would otherwise create ephemeral sessions. + Regression: plugin refactor 924bc67e dropped gateway key plumbing. + """ + + def test_gateway_key_overrides_per_session_strategy(self): + """gateway_session_key must win over per-session session_id.""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_id="20260412_171002_69bb38", + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "agent-main-telegram-dm-8439114563" + + def test_session_title_still_wins_over_gateway_key(self): + """Explicit /title remap takes priority over gateway_session_key.""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_title="my-custom-title", + session_id="20260412_171002_69bb38", + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "my-custom-title" + + def test_per_session_fallback_without_gateway_key(self): + """Without gateway_session_key, per-session returns session_id (CLI path).""" + config = HonchoClientConfig(session_strategy="per-session") + result = config.resolve_session_name( + session_id="20260412_171002_69bb38", + gateway_session_key=None, + ) + assert result == "20260412_171002_69bb38" + + def test_gateway_key_sanitizes_special_chars(self): + """Colons and other non-alphanumeric chars are replaced with hyphens.""" config = HonchoClientConfig() - assert config.init_on_session_start is False - - def test_root_level_true(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "initOnSessionStart": True, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is True - - def test_host_block_overrides_root(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "initOnSessionStart": True, - "hosts": {"hermes": {"initOnSessionStart": False}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is False - - def test_host_block_true_overrides_root_absent(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({ - "apiKey": "k", - "hosts": {"hermes": {"initOnSessionStart": True}}, - })) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is True - - def test_absent_everywhere_defaults_false(self, tmp_path): - cfg_file = tmp_path / "config.json" - cfg_file.write_text(json.dumps({"apiKey": "k"})) - cfg = HonchoClientConfig.from_global_config(config_path=cfg_file) - assert cfg.init_on_session_start is False + result = config.resolve_session_name( + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + assert result == "agent-main-telegram-dm-8439114563" + assert ":" not in result class TestResetHonchoClient: @@ -549,3 +663,91 @@ class TestResetHonchoClient: assert mod._honcho_client is not None reset_honcho_client() assert mod._honcho_client is None + + +class TestDialecticDepthParsing: + """Tests for _parse_dialectic_depth and _parse_dialectic_depth_levels.""" + + def test_default_depth_is_1(self, tmp_path): + """Default dialecticDepth should be 1.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***"})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth == 1 + + def test_depth_from_root(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "dialecticDepth": 2})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth == 2 + + def test_depth_host_block_wins(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "dialecticDepth": 1, + "hosts": {"hermes": {"dialecticDepth": 3}}, + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth == 3 + + def test_depth_clamped_high(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "dialecticDepth": 10})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth == 3 + + def test_depth_clamped_low(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***", "dialecticDepth": -1})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth == 1 + + def test_depth_levels_default_none(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "***"})) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth_levels is None + + def test_depth_levels_from_config(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "dialecticDepth": 2, + "dialecticDepthLevels": ["minimal", "high"], + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth_levels == ["minimal", "high"] + + def test_depth_levels_padded_if_short(self, tmp_path): + """Array shorter than depth gets padded with 'low'.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "dialecticDepth": 3, + "dialecticDepthLevels": ["high"], + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth_levels == ["high", "low", "low"] + + def test_depth_levels_truncated_if_long(self, tmp_path): + """Array longer than depth gets truncated.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "dialecticDepth": 1, + "dialecticDepthLevels": ["high", "max", "medium"], + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth_levels == ["high"] + + def test_depth_levels_invalid_values_default_to_low(self, tmp_path): + """Invalid reasoning levels in the array fall back to 'low'.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "dialecticDepth": 2, + "dialecticDepthLevels": ["invalid", "high"], + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.dialectic_depth_levels == ["low", "high"] diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index abf6dee00..69c024efe 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -205,27 +205,62 @@ class TestPeerLookupHelpers: def test_get_peer_card_uses_direct_peer_lookup(self): mgr, session = self._make_cached_manager() - user_peer = MagicMock() - user_peer.get_card.return_value = ["Name: Robert"] - mgr._get_or_create_peer = MagicMock(return_value=user_peer) + assistant_peer = MagicMock() + assistant_peer.get_card.return_value = ["Name: Robert"] + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) assert mgr.get_peer_card(session.key) == ["Name: Robert"] - user_peer.get_card.assert_called_once_with() + assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id) - def test_search_context_uses_peer_context_response(self): + def test_search_context_uses_assistant_perspective_with_target(self): mgr, session = self._make_cached_manager() - user_peer = MagicMock() - user_peer.context.return_value = SimpleNamespace( + assistant_peer = MagicMock() + assistant_peer.context.return_value = SimpleNamespace( representation="Robert runs neuralancer", peer_card=["Location: Melbourne"], ) - mgr._get_or_create_peer = MagicMock(return_value=user_peer) + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) result = mgr.search_context(session.key, "neuralancer") assert "Robert runs neuralancer" in result assert "- Location: Melbourne" in result - user_peer.context.assert_called_once_with(search_query="neuralancer") + assistant_peer.context.assert_called_once_with( + target=session.user_peer_id, + search_query="neuralancer", + ) + + def test_search_context_unified_mode_uses_user_self_context(self): + mgr, session = self._make_cached_manager() + mgr._ai_observe_others = False + user_peer = MagicMock() + user_peer.context.return_value = SimpleNamespace( + representation="Unified self context", + peer_card=["Name: Robert"], + ) + mgr._get_or_create_peer = MagicMock(return_value=user_peer) + + result = mgr.search_context(session.key, "self") + + assert "Unified self context" in result + user_peer.context.assert_called_once_with(search_query="self") + + def test_search_context_accepts_explicit_ai_peer_id(self): + mgr, session = self._make_cached_manager() + ai_peer = MagicMock() + ai_peer.context.return_value = SimpleNamespace( + representation="Assistant self context", + peer_card=["Role: Assistant"], + ) + mgr._get_or_create_peer = MagicMock(return_value=ai_peer) + + result = mgr.search_context(session.key, "assistant", peer=session.assistant_peer_id) + + assert "Assistant self context" in result + ai_peer.context.assert_called_once_with( + target=session.assistant_peer_id, + search_query="assistant", + ) def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self): mgr, session = self._make_cached_manager() @@ -235,9 +270,15 @@ class TestPeerLookupHelpers: peer_card=["Name: Robert"], ) ai_peer = MagicMock() - ai_peer.context.return_value = SimpleNamespace( - representation="AI representation", - peer_card=["Owner: Robert"], + ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace( + representation=( + "AI representation" if kwargs.get("target") == session.assistant_peer_id + else "Mixed representation" + ), + peer_card=( + ["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id + else ["Name: Robert"] + ), ) mgr._get_or_create_peer = MagicMock(side_effect=[user_peer, ai_peer]) @@ -247,17 +288,23 @@ class TestPeerLookupHelpers: "representation": "User representation", "card": "Name: Robert", "ai_representation": "AI representation", - "ai_card": "Owner: Robert", + "ai_card": "Role: Assistant", } - user_peer.context.assert_called_once_with() - ai_peer.context.assert_called_once_with() + user_peer.context.assert_called_once_with(target=session.user_peer_id) + ai_peer.context.assert_called_once_with(target=session.assistant_peer_id) def test_get_ai_representation_uses_peer_api(self): mgr, session = self._make_cached_manager() ai_peer = MagicMock() - ai_peer.context.return_value = SimpleNamespace( - representation="AI representation", - peer_card=["Owner: Robert"], + ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace( + representation=( + "AI representation" if kwargs.get("target") == session.assistant_peer_id + else "Mixed representation" + ), + peer_card=( + ["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id + else ["Name: Robert"] + ), ) mgr._get_or_create_peer = MagicMock(return_value=ai_peer) @@ -265,9 +312,167 @@ class TestPeerLookupHelpers: assert result == { "representation": "AI representation", - "card": "Owner: Robert", + "card": "Role: Assistant", } - ai_peer.context.assert_called_once_with() + ai_peer.context.assert_called_once_with(target=session.assistant_peer_id) + + def test_create_conclusion_defaults_to_user_target(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "User prefers dark mode") + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id) + scope.create.assert_called_once_with([{ + "content": "User prefers dark mode", + "session_id": session.honcho_session_id, + }]) + + def test_create_conclusion_can_target_ai_peer(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "Assistant prefers terse summaries", peer="ai") + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.assistant_peer_id) + scope.create.assert_called_once_with([{ + "content": "Assistant prefers terse summaries", + "session_id": session.honcho_session_id, + }]) + + def test_create_conclusion_accepts_explicit_user_peer_id(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + scope = MagicMock() + assistant_peer.conclusions_of.return_value = scope + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + ok = mgr.create_conclusion(session.key, "Robert prefers vinyl", peer=session.user_peer_id) + + assert ok is True + assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id) + scope.create.assert_called_once_with([{ + "content": "Robert prefers vinyl", + "session_id": session.honcho_session_id, + }]) + + +class TestConcludeToolDispatch: + def test_honcho_conclude_defaults_to_user_peer(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.create_conclusion.return_value = True + + result = provider.handle_tool_call( + "honcho_conclude", + {"conclusion": "User prefers dark mode"}, + ) + + assert "Conclusion saved for user" in result + provider._manager.create_conclusion.assert_called_once_with( + "telegram:123", + "User prefers dark mode", + peer="user", + ) + + def test_honcho_conclude_can_target_ai_peer(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.create_conclusion.return_value = True + + result = provider.handle_tool_call( + "honcho_conclude", + {"conclusion": "Assistant likes terse replies", "peer": "ai"}, + ) + + assert "Conclusion saved for ai" in result + provider._manager.create_conclusion.assert_called_once_with( + "telegram:123", + "Assistant likes terse replies", + peer="ai", + ) + + def test_honcho_profile_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.get_peer_card.return_value = ["Role: Assistant"] + + result = provider.handle_tool_call( + "honcho_profile", + {"peer": "hermes"}, + ) + + assert "Role: Assistant" in result + provider._manager.get_peer_card.assert_called_once_with("telegram:123", peer="hermes") + + def test_honcho_search_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.search_context.return_value = "Assistant self context" + + result = provider.handle_tool_call( + "honcho_search", + {"query": "assistant", "peer": "hermes"}, + ) + + assert "Assistant self context" in result + provider._manager.search_context.assert_called_once_with( + "telegram:123", + "assistant", + max_tokens=800, + peer="hermes", + ) + + def test_honcho_reasoning_can_target_explicit_peer_id(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = "Assistant answer" + + result = provider.handle_tool_call( + "honcho_reasoning", + {"query": "who are you", "peer": "hermes"}, + ) + + assert "Assistant answer" in result + provider._manager.dialectic_query.assert_called_once_with( + "telegram:123", + "who are you", + reasoning_level=None, + peer="hermes", + ) + + def test_honcho_conclude_missing_both_params_returns_error(self): + """Calling honcho_conclude with neither conclusion nor delete_id returns a tool error.""" + import json + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + + result = provider.handle_tool_call("honcho_conclude", {}) + + parsed = json.loads(result) + assert "error" in parsed or "Missing required" in parsed.get("result", "") + provider._manager.create_conclusion.assert_not_called() + provider._manager.delete_conclusion.assert_not_called() # --------------------------------------------------------------------------- @@ -366,6 +571,54 @@ class TestToolsModeInitBehavior: assert cfg.peer_name == "8439114563" +class TestPerSessionMigrateGuard: + """Verify migrate_memory_files is skipped under per-session strategy. + + per-session creates a fresh Honcho session every Hermes run. Uploading + MEMORY.md/USER.md/SOUL.md to each short-lived session floods the backend + with duplicate content. The guard was added to prevent orphan sessions + containing only wrappers. + """ + + def _make_provider_with_strategy(self, strategy, init_on_session_start=True): + """Create a HonchoMemoryProvider and track migrate_memory_files calls.""" + from plugins.memory.honcho.client import HonchoClientConfig + from unittest.mock import patch, MagicMock + + cfg = HonchoClientConfig( + api_key="test-key", + enabled=True, + recall_mode="tools", + init_on_session_start=init_on_session_start, + session_strategy=strategy, + ) + + provider = HonchoMemoryProvider() + + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] # empty = new session → triggers migration path + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-001") + + return provider, mock_manager + + def test_migrate_skipped_for_per_session(self): + """per-session strategy must NOT call migrate_memory_files.""" + _, mock_manager = self._make_provider_with_strategy("per-session") + mock_manager.migrate_memory_files.assert_not_called() + + def test_migrate_runs_for_per_directory(self): + """per-directory strategy with empty session SHOULD call migrate_memory_files.""" + _, mock_manager = self._make_provider_with_strategy("per-directory") + mock_manager.migrate_memory_files.assert_called_once() + + class TestChunkMessage: def test_short_message_single_chunk(self): result = HonchoMemoryProvider._chunk_message("hello world", 100) @@ -420,6 +673,60 @@ class TestChunkMessage: assert len(chunk) <= 25000 +# --------------------------------------------------------------------------- +# Context token budget enforcement +# --------------------------------------------------------------------------- + + +class TestTruncateToBudget: + def test_truncates_oversized_context(self): + """Text exceeding context_tokens budget is truncated at a word boundary.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=10) + + long_text = "word " * 200 # ~1000 chars, well over 10*4=40 char budget + result = provider._truncate_to_budget(long_text) + + assert len(result) <= 50 # budget_chars + ellipsis + word boundary slack + assert result.endswith(" …") + + def test_no_truncation_within_budget(self): + """Text within budget passes through unchanged.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=1000) + + short_text = "Name: Robert, Location: Melbourne" + assert provider._truncate_to_budget(short_text) == short_text + + def test_no_truncation_when_context_tokens_none(self): + """When context_tokens is None (explicit opt-out), no truncation.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=None) + + long_text = "word " * 500 + assert provider._truncate_to_budget(long_text) == long_text + + def test_context_tokens_cap_bounds_prefetch(self): + """With an explicit token budget, oversized prefetch is bounded.""" + from plugins.memory.honcho.client import HonchoClientConfig + + provider = HonchoMemoryProvider() + provider._config = HonchoClientConfig(context_tokens=1200) + + # Simulate a massive representation (10k chars) + huge_text = "x" * 10000 + result = provider._truncate_to_budget(huge_text) + + # 1200 tokens * 4 chars = 4800 chars + " …" + assert len(result) <= 4805 + + # --------------------------------------------------------------------------- # Dialectic input guard # --------------------------------------------------------------------------- @@ -452,3 +759,387 @@ class TestDialecticInputGuard: # The query passed to chat() should be truncated actual_query = mock_peer.chat.call_args[0][0] assert len(actual_query) <= 100 + + +# --------------------------------------------------------------------------- + + +class TestDialecticCadenceDefaults: + """Regression tests for dialectic_cadence default value.""" + + @staticmethod + def _make_provider(cfg_extra=None): + """Create a HonchoMemoryProvider with mocked dependencies.""" + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-001") + + return provider + + def test_default_is_3(self): + """Default dialectic_cadence should be 3 to avoid per-turn LLM calls.""" + provider = self._make_provider() + assert provider._dialectic_cadence == 3 + + def test_config_override(self): + """dialecticCadence from config overrides the default.""" + provider = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 5}}) + assert provider._dialectic_cadence == 5 + + +class TestBaseContextSummary: + """Base context injection should include session summary when available.""" + + def test_format_includes_summary(self): + """Session summary should appear first in the formatted context.""" + provider = HonchoMemoryProvider() + ctx = { + "summary": "Testing Honcho tools and dialectic depth.", + "representation": "Eri is a developer.", + "card": "Name: Eri Barrett", + } + formatted = provider._format_first_turn_context(ctx) + assert "## Session Summary" in formatted + assert formatted.index("Session Summary") < formatted.index("User Representation") + + def test_format_without_summary(self): + """No summary key means no summary section.""" + provider = HonchoMemoryProvider() + ctx = {"representation": "Eri is a developer.", "card": "Name: Eri"} + formatted = provider._format_first_turn_context(ctx) + assert "Session Summary" not in formatted + assert "User Representation" in formatted + + def test_format_empty_summary_skipped(self): + """Empty summary string should not produce a section.""" + provider = HonchoMemoryProvider() + ctx = {"summary": "", "representation": "rep", "card": "card"} + formatted = provider._format_first_turn_context(ctx) + assert "Session Summary" not in formatted + + +class TestDialecticDepth: + """Tests for the dialecticDepth multi-pass system.""" + + @staticmethod + def _make_provider(cfg_extra=None): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-001") + + return provider + + def test_default_depth_is_1(self): + """Default dialecticDepth should be 1 — single .chat() call.""" + provider = self._make_provider() + assert provider._dialectic_depth == 1 + + def test_depth_from_config(self): + """dialecticDepth from config sets the depth.""" + provider = self._make_provider(cfg_extra={"dialectic_depth": 2}) + assert provider._dialectic_depth == 2 + + def test_depth_clamped_to_3(self): + """dialecticDepth > 3 gets clamped to 3.""" + provider = self._make_provider(cfg_extra={"dialectic_depth": 7}) + assert provider._dialectic_depth == 3 + + def test_depth_clamped_to_1(self): + """dialecticDepth < 1 gets clamped to 1.""" + provider = self._make_provider(cfg_extra={"dialectic_depth": 0}) + assert provider._dialectic_depth == 1 + + def test_depth_levels_from_config(self): + """dialecticDepthLevels array is read from config.""" + provider = self._make_provider(cfg_extra={ + "dialectic_depth": 2, + "dialectic_depth_levels": ["minimal", "high"], + }) + assert provider._dialectic_depth_levels == ["minimal", "high"] + + def test_depth_levels_none_by_default(self): + """When dialecticDepthLevels is not configured, it's None.""" + provider = self._make_provider() + assert provider._dialectic_depth_levels is None + + def test_resolve_pass_level_uses_depth_levels(self): + """Per-pass levels from dialecticDepthLevels override proportional.""" + provider = self._make_provider(cfg_extra={ + "dialectic_depth": 2, + "dialectic_depth_levels": ["minimal", "high"], + }) + assert provider._resolve_pass_level(0) == "minimal" + assert provider._resolve_pass_level(1) == "high" + + def test_resolve_pass_level_proportional_depth_1(self): + """Depth 1 pass 0 uses the base reasoning level.""" + provider = self._make_provider(cfg_extra={ + "dialectic_depth": 1, + "dialectic_reasoning_level": "medium", + }) + assert provider._resolve_pass_level(0) == "medium" + + def test_resolve_pass_level_proportional_depth_2(self): + """Depth 2: pass 0 is minimal, pass 1 is base level.""" + provider = self._make_provider(cfg_extra={ + "dialectic_depth": 2, + "dialectic_reasoning_level": "high", + }) + assert provider._resolve_pass_level(0) == "minimal" + assert provider._resolve_pass_level(1) == "high" + + def test_cold_start_prompt(self): + """Cold start (no base context) uses general user query.""" + provider = self._make_provider() + prompt = provider._build_dialectic_prompt(0, [], is_cold=True) + assert "preferences" in prompt.lower() + assert "session" not in prompt.lower() + + def test_warm_session_prompt(self): + """Warm session (has context) uses session-scoped query.""" + provider = self._make_provider() + prompt = provider._build_dialectic_prompt(0, [], is_cold=False) + assert "session" in prompt.lower() + assert "current conversation" in prompt.lower() + + def test_signal_sufficient_short_response(self): + """Short responses are not sufficient signal.""" + assert not HonchoMemoryProvider._signal_sufficient("ok") + assert not HonchoMemoryProvider._signal_sufficient("") + assert not HonchoMemoryProvider._signal_sufficient(None) + + def test_signal_sufficient_structured_response(self): + """Structured responses with bullets/headers are sufficient.""" + result = "## Current State\n- Working on Honcho PR\n- Testing dialectic depth\n" + "x" * 50 + assert HonchoMemoryProvider._signal_sufficient(result) + + def test_signal_sufficient_long_unstructured(self): + """Long responses are sufficient even without structure.""" + assert HonchoMemoryProvider._signal_sufficient("a" * 301) + + def test_run_dialectic_depth_single_pass(self): + """Depth 1 makes exactly one .chat() call.""" + from unittest.mock import MagicMock + provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = "user prefers zero-fluff" + provider._session_key = "test" + provider._base_context_cache = None # cold start + + result = provider._run_dialectic_depth("hello") + assert result == "user prefers zero-fluff" + assert provider._manager.dialectic_query.call_count == 1 + + def test_run_dialectic_depth_two_passes(self): + """Depth 2 makes two .chat() calls when pass 1 signal is weak.""" + from unittest.mock import MagicMock + provider = self._make_provider(cfg_extra={"dialectic_depth": 2}) + provider._manager = MagicMock() + provider._manager.dialectic_query.side_effect = [ + "thin response", # pass 0: weak signal + "## Synthesis\n- Grounded in evidence\n- Current PR work\n" + "x" * 100, # pass 1: strong + ] + provider._session_key = "test" + provider._base_context_cache = "existing context" + + result = provider._run_dialectic_depth("test query") + assert provider._manager.dialectic_query.call_count == 2 + assert "Synthesis" in result + + def test_first_turn_runs_dialectic_synchronously(self): + """First turn should fire the dialectic synchronously (cold start).""" + from unittest.mock import MagicMock, patch + provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = "cold start synthesis" + provider._manager.get_prefetch_context.return_value = None + provider._manager.pop_context_result.return_value = None + provider._session_key = "test" + provider._base_context_cache = "" # cold start + provider._last_dialectic_turn = -999 # never fired + + result = provider.prefetch("hello world") + assert "cold start synthesis" in result + assert provider._manager.dialectic_query.call_count == 1 + # After first-turn sync, _last_dialectic_turn should be updated + assert provider._last_dialectic_turn != -999 + + def test_first_turn_dialectic_does_not_double_fire(self): + """After first-turn sync dialectic, queue_prefetch should skip (cadence).""" + from unittest.mock import MagicMock + provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = "cold start synthesis" + provider._manager.get_prefetch_context.return_value = None + provider._manager.pop_context_result.return_value = None + provider._session_key = "test" + provider._base_context_cache = "" + provider._last_dialectic_turn = -999 + provider._turn_count = 0 + + # First turn fires sync dialectic + provider.prefetch("hello") + assert provider._manager.dialectic_query.call_count == 1 + + # Now queue_prefetch on same turn should skip (cadence: 0 - 0 < 3) + provider._manager.dialectic_query.reset_mock() + provider.queue_prefetch("hello") + assert provider._manager.dialectic_query.call_count == 0 + + def test_run_dialectic_depth_bails_early_on_strong_signal(self): + """Depth 2 skips pass 1 when pass 0 returns strong signal.""" + from unittest.mock import MagicMock + provider = self._make_provider(cfg_extra={"dialectic_depth": 2}) + provider._manager = MagicMock() + provider._manager.dialectic_query.return_value = ( + "## Full Assessment\n- Strong structured response\n- With evidence\n" + "x" * 200 + ) + provider._session_key = "test" + provider._base_context_cache = "existing context" + + result = provider._run_dialectic_depth("test query") + # Only 1 call because pass 0 had sufficient signal + assert provider._manager.dialectic_query.call_count == 1 + + +# --------------------------------------------------------------------------- +# set_peer_card None guard +# --------------------------------------------------------------------------- + + +class TestSetPeerCardNoneGuard: + """set_peer_card must return None (not raise) when peer ID cannot be resolved.""" + + def _make_manager(self): + from plugins.memory.honcho.client import HonchoClientConfig + from plugins.memory.honcho.session import HonchoSessionManager + + cfg = HonchoClientConfig(api_key="test-key", enabled=True) + mgr = HonchoSessionManager.__new__(HonchoSessionManager) + mgr._cache = {} + mgr._sessions_cache = {} + mgr._config = cfg + return mgr + + def test_returns_none_when_peer_resolves_to_none(self): + """set_peer_card returns None when _resolve_peer_id returns None.""" + from unittest.mock import patch + mgr = self._make_manager() + + session = HonchoSession( + key="test", + honcho_session_id="sid", + user_peer_id="user-peer", + assistant_peer_id="ai-peer", + ) + mgr._cache["test"] = session + + with patch.object(mgr, "_resolve_peer_id", return_value=None): + result = mgr.set_peer_card("test", ["fact 1", "fact 2"], peer="ghost") + + assert result is None + + def test_returns_none_when_session_missing(self): + """set_peer_card returns None when session key is not in cache.""" + mgr = self._make_manager() + result = mgr.set_peer_card("nonexistent", ["fact"], peer="user") + assert result is None + + +# --------------------------------------------------------------------------- +# get_session_context cache-miss fallback respects peer param +# --------------------------------------------------------------------------- + + +class TestGetSessionContextFallback: + """get_session_context fallback must honour the peer param when honcho_session is absent.""" + + def _make_manager_with_session(self, user_peer_id="user-peer", assistant_peer_id="ai-peer"): + from plugins.memory.honcho.client import HonchoClientConfig + from plugins.memory.honcho.session import HonchoSessionManager + + cfg = HonchoClientConfig(api_key="test-key", enabled=True) + mgr = HonchoSessionManager.__new__(HonchoSessionManager) + mgr._cache = {} + mgr._sessions_cache = {} + mgr._config = cfg + mgr._dialectic_dynamic = True + mgr._dialectic_reasoning_level = "low" + mgr._dialectic_max_input_chars = 10000 + mgr._ai_observe_others = True + + session = HonchoSession( + key="test", + honcho_session_id="sid-missing-from-sessions-cache", + user_peer_id=user_peer_id, + assistant_peer_id=assistant_peer_id, + ) + mgr._cache["test"] = session + # Deliberately NOT adding to _sessions_cache to trigger fallback path + return mgr + + def test_fallback_uses_user_peer_for_user(self): + """On cache miss, peer='user' fetches user peer context.""" + mgr = self._make_manager_with_session() + fetch_calls = [] + + def _fake_fetch(peer_id, search_query=None, *, target=None): + fetch_calls.append((peer_id, target)) + return {"representation": "user rep", "card": []} + + mgr._fetch_peer_context = _fake_fetch + + mgr.get_session_context("test", peer="user") + + assert len(fetch_calls) == 1 + peer_id, target = fetch_calls[0] + assert peer_id == "user-peer" + assert target == "user-peer" + + def test_fallback_uses_ai_peer_for_ai(self): + """On cache miss, peer='ai' fetches assistant peer context, not user.""" + mgr = self._make_manager_with_session() + fetch_calls = [] + + def _fake_fetch(peer_id, search_query=None, *, target=None): + fetch_calls.append((peer_id, target)) + return {"representation": "ai rep", "card": []} + + mgr._fetch_peer_context = _fake_fetch + + mgr.get_session_context("test", peer="ai") + + assert len(fetch_calls) == 1 + peer_id, target = fetch_calls[0] + assert peer_id == "ai-peer", f"expected ai-peer, got {peer_id}" + assert target == "ai-peer" diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d71e6a625..5ff1491e4 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3998,3 +3998,63 @@ class TestDeadRetryCode: f"Expected 2 occurrences of 'if retry_count >= max_retries:' " f"but found {occurrences}" ) + + +class TestMemoryContextSanitization: + """run_conversation() must strip leaked blocks from user input.""" + + def test_memory_context_stripped_from_user_message(self): + """Verify that blocks are removed before the message + enters the conversation loop — prevents stale Honcho injection from + leaking into user text.""" + import inspect + src = inspect.getsource(AIAgent.run_conversation) + # The sanitize_context call must appear in run_conversation's preamble + assert "sanitize_context(user_message)" in src + assert "sanitize_context(persist_user_message)" in src + + def test_sanitize_context_strips_full_block(self): + """End-to-end: a user message with an embedded memory-context block + is cleaned to just the actual user text.""" + from agent.memory_manager import sanitize_context + user_text = "how is the honcho working" + injected = ( + user_text + "\n\n" + "\n" + "[System note: The following is recalled memory context, " + "NOT new user input. Treat as informational background data.]\n\n" + "## User Representation\n" + "[2026-01-13 02:13:00] stale observation about AstroMap\n" + "" + ) + result = sanitize_context(injected) + assert "memory-context" not in result.lower() + assert "stale observation" not in result + assert "how is the honcho working" in result + + +class TestMemoryProviderTurnStart: + """run_conversation() must call memory_manager.on_turn_start() before prefetch_all(). + + Without this call, providers like Honcho never update _turn_count, so cadence + checks (contextCadence, dialecticCadence) are always satisfied — every turn + fires both context refresh and dialectic, ignoring the configured cadence. + """ + + def test_on_turn_start_called_before_prefetch(self): + """Source-level check: on_turn_start appears before prefetch_all in run_conversation.""" + import inspect + src = inspect.getsource(AIAgent.run_conversation) + # Find the actual method calls, not comments + idx_turn_start = src.index(".on_turn_start(") + idx_prefetch = src.index(".prefetch_all(") + assert idx_turn_start < idx_prefetch, ( + "on_turn_start() must be called before prefetch_all() in run_conversation " + "so that memory providers have the correct turn count for cadence checks" + ) + + def test_on_turn_start_uses_user_turn_count(self): + """Source-level check: on_turn_start receives self._user_turn_count.""" + import inspect + src = inspect.getsource(AIAgent.run_conversation) + assert "on_turn_start(self._user_turn_count" in src diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 06f7a0e3e..56c47f833 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -72,7 +72,7 @@ In addition to built-in tools, Hermes can load tools dynamically from MCP server | `ha_list_services` | List available Home Assistant services (actions) for device control. Shows what actions can be performed on each device type and what parameters they accept. Use this to discover how to control devices found via ha_list_entities. | — | :::note -**Honcho tools** (`honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`) are no longer built-in. They are available via the Honcho memory provider plugin at `plugins/memory/honcho/`. See [Plugins](../user-guide/features/plugins.md) for installation and usage. +**Honcho tools** (`honcho_profile`, `honcho_search`, `honcho_context`, `honcho_reasoning`, `honcho_conclude`) are no longer built-in. They are available via the Honcho memory provider plugin at `plugins/memory/honcho/`. See [Memory Providers](../user-guide/features/memory-providers.md) for installation and usage. ::: ## `image_gen` toolset diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 4d8c777c6..2040949d2 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -18,12 +18,15 @@ Honcho is integrated into the [Memory Providers](./memory-providers.md) system. |-----------|----------------|--------| | Cross-session persistence | ✔ File-based MEMORY.md/USER.md | ✔ Server-side with API | | User profile | ✔ Manual agent curation | ✔ Automatic dialectic reasoning | +| Session summary | — | ✔ Session-scoped context injection | | Multi-agent isolation | — | ✔ Per-peer profile separation | | Observation modes | — | ✔ Unified or directional observation | | Conclusions (derived insights) | — | ✔ Server-side reasoning about patterns | | Search across history | ✔ FTS5 session search | ✔ Semantic search over conclusions | -**Dialectic reasoning**: After each conversation, Honcho analyzes the exchange and derives "conclusions" — insights about the user's preferences, habits, and goals. These conclusions accumulate over time, giving the agent a deepening understanding that goes beyond what the user explicitly stated. +**Dialectic reasoning**: After each conversation turn (gated by `dialecticCadence`), Honcho analyzes the exchange and derives insights about the user's preferences, habits, and goals. These accumulate over time, giving the agent a deepening understanding that goes beyond what the user explicitly stated. The dialectic supports multi-pass depth (1–3 passes) with automatic cold/warm prompt selection — cold start queries focus on general user facts while warm queries prioritize session-scoped context. + +**Session-scoped context**: Base context now includes the session summary alongside the user representation and peer card. This gives the agent awareness of what has already been discussed in the current session, reducing repetition and enabling continuity. **Multi-agent profiles**: When multiple Hermes instances talk to the same user (e.g., a coding assistant and a personal assistant), Honcho maintains separate "peer" profiles. Each peer sees only its own observations and conclusions, preventing cross-contamination of context. @@ -42,40 +45,128 @@ memory: ``` ```bash -echo "HONCHO_API_KEY=your-key" >> ~/.hermes/.env +echo "HONCHO_API_KEY=*** >> ~/.hermes/.env ``` Get an API key at [honcho.dev](https://honcho.dev). +## Architecture + +### Two-Layer Context Injection + +Every turn (in `hybrid` or `context` mode), Honcho assembles two layers of context injected into the system prompt: + +1. **Base context** — session summary, user representation, user peer card, AI self-representation, and AI identity card. Refreshed on `contextCadence`. This is the "who is this user" layer. +2. **Dialectic supplement** — LLM-synthesized reasoning about the user's current state and needs. Refreshed on `dialecticCadence`. This is the "what matters right now" layer. + +Both layers are concatenated and truncated to the `contextTokens` budget (if set). + +### Cold/Warm Prompt Selection + +The dialectic automatically selects between two prompt strategies: + +- **Cold start** (no base context yet): General query — "Who is this person? What are their preferences, goals, and working style?" +- **Warm session** (base context exists): Session-scoped query — "Given what's been discussed in this session so far, what context about this user is most relevant?" + +This happens automatically based on whether base context has been populated. + +### Three Orthogonal Config Knobs + +Cost and depth are controlled by three independent knobs: + +| Knob | Controls | Default | +|------|----------|---------| +| `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` | +| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` | +| `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` | + +These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes. + +### Dialectic Depth (Multi-Pass) + +When `dialecticDepth` > 1, each dialectic invocation runs multiple `.chat()` passes: + +- **Pass 0**: Cold or warm prompt (see above) +- **Pass 1**: Self-audit — identifies gaps in the initial assessment and synthesizes evidence from recent sessions +- **Pass 2**: Reconciliation — checks for contradictions between prior passes and produces a final synthesis + +Each pass uses a proportional reasoning level (lighter early passes, base level for the main pass). Override per-pass levels with `dialecticDepthLevels` — e.g., `["minimal", "medium", "high"]` for a depth-3 run. + +Passes bail out early if the prior pass returned strong signal (long, structured output), so depth 3 doesn't always mean 3 LLM calls. + ## Configuration Options -```yaml -# ~/.hermes/config.yaml -honcho: - observation: directional # "unified" (default for new installs) or "directional" - peer_name: "" # auto-detected from platform, or set manually -``` +Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you. -**Observation modes:** -- `unified` — All observations go into a single pool. Simpler, good for single-agent setups. -- `directional` — Observations are tagged with direction (user→agent, agent→user). Enables richer analysis of conversation dynamics. +### Full Config Reference + +| Key | Default | Description | +|-----|---------|-------------| +| `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries | +| `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | +| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | +| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 | +| `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | +| `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | When `true`, model can override reasoning level per-call via tool param | +| `dialecticMaxChars` | `600` | Max chars of dialectic result injected into system prompt | +| `recallMode` | `'hybrid'` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) | +| `writeFrequency` | `'async'` | When to flush messages: `async` (background thread), `turn` (sync), `session` (batch on end), or integer N | +| `saveMessages` | `true` | Whether to persist messages to Honcho API | +| `observationMode` | `'directional'` | `directional` (all on) or `unified` (shared pool). Override with `observation` object for granular control | +| `messageMaxChars` | `25000` | Max chars per message sent via `add_messages()`. Chunked if exceeded | +| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | +| `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | + +**Session strategy** controls how Honcho sessions map to your work: +- `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. +- `per-directory` — one Honcho session per working directory. Context accumulates across runs. +- `per-repo` — one session per git repository. +- `global` — single session across all directories. + +**Recall mode** controls how memory flows into conversations: +- `hybrid` — context auto-injected into system prompt AND tools available (model decides when to query). +- `context` — auto-injection only, tools hidden. +- `tools` — tools only, no auto-injection. Agent must explicitly call `honcho_reasoning`, `honcho_search`, etc. + +**Settings per recall mode:** + +| Setting | `hybrid` | `context` | `tools` | +|---------|----------|-----------|---------| +| `writeFrequency` | flushes messages | flushes messages | flushes messages | +| `contextCadence` | gates base context refresh | gates base context refresh | irrelevant — no injection | +| `dialecticCadence` | gates auto LLM calls | gates auto LLM calls | irrelevant — model calls explicitly | +| `dialecticDepth` | multi-pass per invocation | multi-pass per invocation | irrelevant — model calls explicitly | +| `contextTokens` | caps injection | caps injection | irrelevant — no injection | +| `dialecticDynamic` | gates model override | N/A (no tools) | gates model override | + +In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`). ## Tools -When Honcho is active as the memory provider, four additional tools become available: +When Honcho is active as the memory provider, five tools become available: | Tool | Purpose | |------|---------| -| `honcho_conclude` | Trigger server-side dialectic reasoning on recent conversations | -| `honcho_context` | Retrieve relevant context from Honcho's memory for the current conversation | -| `honcho_profile` | View or update the user's Honcho profile | -| `honcho_search` | Semantic search across all stored conclusions and observations | +| `honcho_profile` | Read or update peer card — pass `card` (list of facts) to update, omit to read | +| `honcho_search` | Semantic search over context — raw excerpts, no LLM synthesis | +| `honcho_context` | Full session context — summary, representation, card, recent messages | +| `honcho_reasoning` | Synthesized answer from Honcho's LLM — pass `reasoning_level` (minimal/low/medium/high/max) to control depth | +| `honcho_conclude` | Create or delete conclusions — pass `conclusion` to create, `delete_id` to remove (PII only) | ## CLI Commands ```bash -hermes honcho status # Show connection status and config +hermes honcho status # Connection status, config, and key settings +hermes honcho setup # Interactive setup wizard +hermes honcho strategy # Show or set session strategy hermes honcho peer # Update peer names for multi-agent setups +hermes honcho mode # Show or set recall mode +hermes honcho tokens # Show or set context token budget +hermes honcho identity # Show Honcho peer identity +hermes honcho sync # Sync host blocks for all profiles +hermes honcho enable # Enable Honcho +hermes honcho disable # Disable Honcho ``` ## Migrating from `hermes honcho` diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index f9db4ab57..f571c7d48 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -42,7 +42,7 @@ The built-in memory (MEMORY.md / USER.md) continues to work exactly as before. T ### Honcho -AI-native cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions. +AI-native cross-session user modeling with dialectic reasoning, session-scoped context injection, semantic search, and persistent conclusions. Base context now includes the session summary alongside user representation and peer cards, giving the agent awareness of what has already been discussed. | | | |---|---| @@ -51,7 +51,15 @@ AI-native cross-session user modeling with dialectic Q&A, semantic search, and p | **Data storage** | Honcho Cloud or self-hosted | | **Cost** | Honcho pricing (cloud) / free (self-hosted) | -**Tools:** `honcho_profile` (peer card), `honcho_search` (semantic search), `honcho_context` (LLM-synthesized), `honcho_conclude` (store facts) +**Tools (5):** `honcho_profile` (read/update peer card), `honcho_search` (semantic search), `honcho_context` (session context — summary, representation, card, messages), `honcho_reasoning` (LLM-synthesized), `honcho_conclude` (create/delete conclusions) + +**Architecture:** Two-layer context injection — a base layer (session summary + representation + peer card, refreshed on `contextCadence`) plus a dialectic supplement (LLM reasoning, refreshed on `dialecticCadence`). The dialectic automatically selects cold-start prompts (general user facts) vs. warm prompts (session-scoped context) based on whether base context exists. + +**Three orthogonal config knobs** control cost and depth independently: + +- `contextCadence` — how often the base layer refreshes (API call frequency) +- `dialecticCadence` — how often the dialectic LLM fires (LLM call frequency) +- `dialecticDepth` — how many `.chat()` passes per dialectic invocation (1–3, depth of reasoning) **Setup Wizard:** ```bash @@ -63,7 +71,7 @@ hermes memory setup # select "honcho" **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
-Key config options +Full config reference | Key | Default | Description | |-----|---------|-------------| @@ -72,13 +80,21 @@ hermes memory setup # select "honcho" | `peerName` | -- | User peer identity | | `aiPeer` | host key | AI peer identity (one per profile) | | `workspace` | host key | Shared workspace ID | -| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) | -| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans | -| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N | -| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` | -| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` | -| `dialecticDynamic` | `true` | Auto-bump reasoning by query length | +| `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries | +| `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | +| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | +| `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation | +| `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | +| `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | +| `dialecticDynamic` | `true` | When `true`, model can override reasoning level per-call via tool param | +| `dialecticMaxChars` | `600` | Max chars of dialectic result injected into system prompt | +| `recallMode` | `'hybrid'` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) | +| `writeFrequency` | `'async'` | When to flush messages: `async` (background thread), `turn` (sync), `session` (batch on end), or integer N | +| `saveMessages` | `true` | Whether to persist messages to Honcho API | +| `observationMode` | `'directional'` | `directional` (all on) or `unified` (shared pool). Override with `observation` object | | `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | +| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | +| `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, `global` |
@@ -165,7 +181,10 @@ This inherits settings from the default `hermes` host block and creates new AI p }, "dialecticReasoningLevel": "low", "dialecticDynamic": true, + "dialecticCadence": 3, + "dialecticDepth": 1, "dialecticMaxChars": 600, + "contextCadence": 1, "messageMaxChars": 25000, "saveMessages": true }, @@ -462,7 +481,7 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env | Provider | Storage | Cost | Tools | Dependencies | Unique Feature | |----------|---------|------|-------|-------------|----------------| -| **Honcho** | Cloud | Paid | 4 | `honcho-ai` | Dialectic user modeling | +| **Honcho** | Cloud | Paid | 5 | `honcho-ai` | Dialectic user modeling + session-scoped context | | **OpenViking** | Self-hosted | Free | 5 | `openviking` + server | Filesystem hierarchy + tiered loading | | **Mem0** | Cloud | Paid | 3 | `mem0ai` | Server-side LLM extraction | | **Hindsight** | Cloud/Local | Free/Paid | 3 | `hindsight-client` | Knowledge graph + reflect synthesis | From df714add9d797361d0d8fae975fef25f5e52ca60 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:52:46 -0700 Subject: [PATCH 265/849] fix: preserve file permissions on atomic writes (Docker/NAS fix) (#10618) atomic_yaml_write() and atomic_json_write() used tempfile.mkstemp() which creates files with 0o600 (owner-only). After os.replace(), the original file's permissions were destroyed. Combined with _secure_file() forcing 0o600, this broke Docker/NAS setups where volume-mounted config files need broader permissions (e.g. 0o666). Changes: - atomic_yaml_write/atomic_json_write: capture original permissions before write, restore after os.replace() - _secure_file: skip permission tightening in container environments (detected via /.dockerenv, /proc/1/cgroup, or HERMES_SKIP_CHMOD env) - save_env_value: preserve original .env permissions, remove redundant third os.chmod call - remove_env_value: same permission preservation On desktop installs, _secure_file() still tightens to 0o600 as before. In containers, the user's original permissions are respected. Reported by Cedric Weber (Docker/Portainer on NAS). --- hermes_cli/config.py | 61 ++++++++++++++++++++++++++++++++++++++------ utils.py | 32 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4794e74c7..ee66d51a7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -241,13 +241,41 @@ def _secure_dir(path): pass +def _is_container() -> bool: + """Detect if we're running inside a Docker/Podman/LXC container. + + When Hermes runs in a container with volume-mounted config files, forcing + 0o600 permissions breaks multi-process setups where the gateway and + dashboard run as different UIDs or the volume mount requires broader + permissions. + """ + # Explicit opt-out + if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"): + return True + # Docker / Podman marker file + if os.path.exists("/.dockerenv"): + return True + # LXC / cgroup-based detection + try: + with open("/proc/1/cgroup", "r") as f: + cgroup_content = f.read() + if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content: + return True + except (OSError, IOError): + pass + return False + + def _secure_file(path): """Set file to owner-only read/write (0600). No-op on Windows. Skipped in managed mode — the NixOS activation script sets group-readable permissions (0640) on config files. + + Skipped in containers — Docker/Podman volume mounts often need broader + permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems. """ - if is_managed(): + if is_managed() or _is_container(): return try: if os.path.exists(str(path)): @@ -2900,12 +2928,25 @@ def save_env_value(key: str, value: str): lines.append(f"{key}={value}\n") fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') + # Preserve original permissions so Docker volume mounts aren't clobbered. + original_mode = None + if env_path.exists(): + try: + original_mode = stat.S_IMODE(env_path.stat().st_mode) + except OSError: + pass try: with os.fdopen(fd, 'w', **write_kw) as f: f.writelines(lines) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, env_path) + # Restore original permissions before _secure_file may tighten them. + if original_mode is not None: + try: + os.chmod(env_path, original_mode) + except OSError: + pass except BaseException: try: os.unlink(tmp_path) @@ -2916,13 +2957,6 @@ def save_env_value(key: str, value: str): os.environ[key] = value - # Restrict .env permissions to owner-only (contains API keys) - if not _IS_WINDOWS: - try: - os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR) - except OSError: - pass - def remove_env_value(key: str) -> bool: """Remove a key from ~/.hermes/.env and os.environ. @@ -2951,12 +2985,23 @@ def remove_env_value(key: str) -> bool: if found: fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') + # Preserve original permissions so Docker volume mounts aren't clobbered. + original_mode = None + try: + original_mode = stat.S_IMODE(env_path.stat().st_mode) + except OSError: + pass try: with os.fdopen(fd, 'w', **write_kw) as f: f.writelines(new_lines) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, env_path) + if original_mode is not None: + try: + os.chmod(env_path, original_mode) + except OSError: + pass except BaseException: try: os.unlink(tmp_path) diff --git a/utils.py b/utils.py index f967c08ae..cf2582853 100644 --- a/utils.py +++ b/utils.py @@ -3,6 +3,7 @@ import json import logging import os +import stat import tempfile from pathlib import Path from typing import Any, Union @@ -31,6 +32,31 @@ def env_var_enabled(name: str, default: str = "") -> bool: return is_truthy_value(os.getenv(name, default), default=False) +def _preserve_file_mode(path: Path) -> "int | None": + """Capture the permission bits of *path* if it exists, else ``None``.""" + try: + return stat.S_IMODE(path.stat().st_mode) if path.exists() else None + except OSError: + return None + + +def _restore_file_mode(path: Path, mode: "int | None") -> None: + """Re-apply *mode* to *path* after an atomic replace. + + ``tempfile.mkstemp`` creates files with 0o600 (owner-only). After + ``os.replace`` swaps the temp file into place the target inherits + those restrictive permissions, breaking Docker / NAS volume mounts + that rely on broader permissions set by the user. Calling this + right after ``os.replace`` restores the original permissions. + """ + if mode is None: + return + try: + os.chmod(path, mode) + except OSError: + pass + + def atomic_json_write( path: Union[str, Path], data: Any, @@ -54,6 +80,8 @@ def atomic_json_write( path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) + original_mode = _preserve_file_mode(path) + fd, tmp_path = tempfile.mkstemp( dir=str(path.parent), prefix=f".{path.stem}_", @@ -71,6 +99,7 @@ def atomic_json_write( f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) + _restore_file_mode(path, original_mode) except BaseException: # Intentionally catch BaseException so temp-file cleanup still runs for # KeyboardInterrupt/SystemExit before re-raising the original signal. @@ -106,6 +135,8 @@ def atomic_yaml_write( path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) + original_mode = _preserve_file_mode(path) + fd, tmp_path = tempfile.mkstemp( dir=str(path.parent), prefix=f".{path.stem}_", @@ -119,6 +150,7 @@ def atomic_yaml_write( f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) + _restore_file_mode(path, original_mode) except BaseException: # Match atomic_json_write: cleanup must also happen for process-level # interruptions before we re-raise them. From 498b995c1360dc52f91f9c5b0305131c3636745c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:53:11 -0700 Subject: [PATCH 266/849] feat: implement register_command() on plugin context (#10626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the half-built plugin slash command system. The dispatch code in cli.py and gateway/run.py already called get_plugin_command_handler() but the registration side was never implemented. Changes: - Add register_command() to PluginContext — stores handler, description, and plugin name; normalizes names; rejects conflicts with built-in commands - Add _plugin_commands dict to PluginManager - Add commands_registered tracking on LoadedPlugin - Add get_plugin_command_handler() and get_plugin_commands() module-level convenience functions - Fix commands.py to use actual plugin description in Telegram bot menu (was hardcoded 'Plugin command') - Add plugin commands to SlashCommandCompleter autocomplete - Show command count in /plugins display - 12 new tests covering registration, conflict detection, normalization, handler dispatch, and introspection Closes #10495 --- cli.py | 3 +- hermes_cli/commands.py | 18 +++- hermes_cli/plugins.py | 68 +++++++++++++ tests/hermes_cli/test_plugins.py | 163 ++++++++++++++++++++++++++++++- 4 files changed, 246 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index 20996aecc..3a3e8108f 100644 --- a/cli.py +++ b/cli.py @@ -5484,7 +5484,8 @@ class HermesCLI: version = f" v{p['version']}" if p["version"] else "" tools = f"{p['tools']} tools" if p["tools"] else "" hooks = f"{p['hooks']} hooks" if p["hooks"] else "" - parts = [x for x in [tools, hooks] if x] + commands = f"{p['commands']} commands" if p.get("commands") else "" + parts = [x for x in [tools, hooks, commands] if x] detail = f" ({', '.join(parts)})" if parts else "" error = f" — {p['error']}" if p["error"] else "" print(f" {status} {p['name']}{version}{detail}{error}") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c8a0628fa..48ea5bb59 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -450,7 +450,7 @@ def _collect_gateway_skill_entries( name = sanitize_name(cmd_name) if sanitize_name else cmd_name if not name: continue - desc = "Plugin command" + desc = plugin_cmds[cmd_name].get("description", "Plugin command") if len(desc) > desc_limit: desc = desc[:desc_limit - 3] + "..." plugin_pairs.append((name, desc)) @@ -1139,6 +1139,22 @@ class SlashCommandCompleter(Completer): display_meta=f"⚡ {short_desc}", ) + # Plugin-registered slash commands + try: + from hermes_cli.plugins import get_plugin_commands + for cmd_name, cmd_info in get_plugin_commands().items(): + if cmd_name.startswith(word): + desc = str(cmd_info.get("description", "Plugin command")) + short_desc = desc[:50] + ("..." if len(desc) > 50 else "") + yield Completion( + self._completion_text(cmd_name, word), + start_position=-len(word), + display=f"/{cmd_name}", + display_meta=f"🔌 {short_desc}", + ) + except Exception: + pass + # --------------------------------------------------------------------------- # Inline auto-suggest (ghost text) for slash commands diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9d78ca47f..5e8ff8e4f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -112,6 +112,7 @@ class LoadedPlugin: module: Optional[types.ModuleType] = None tools_registered: List[str] = field(default_factory=list) hooks_registered: List[str] = field(default_factory=list) + commands_registered: List[str] = field(default_factory=list) enabled: bool = False error: Optional[str] = None @@ -211,6 +212,53 @@ class PluginContext: } logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name) + # -- slash command registration ------------------------------------------- + + def register_command( + self, + name: str, + handler: Callable, + description: str = "", + ) -> None: + """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. + + The handler signature is ``fn(raw_args: str) -> str | None``. + It may also be an async callable — the gateway dispatch handles both. + + Unlike ``register_cli_command()`` (which creates ``hermes `` + terminal commands), this registers in-session slash commands that users + invoke during a conversation. + + Names conflicting with built-in commands are rejected with a warning. + """ + clean = name.lower().strip().lstrip("/").replace(" ", "-") + if not clean: + logger.warning( + "Plugin '%s' tried to register a command with an empty name.", + self.manifest.name, + ) + return + + # Reject if it conflicts with a built-in command + try: + from hermes_cli.commands import resolve_command + if resolve_command(clean) is not None: + logger.warning( + "Plugin '%s' tried to register command '/%s' which conflicts " + "with a built-in command. Skipping.", + self.manifest.name, clean, + ) + return + except Exception: + pass # If commands module isn't available, skip the check + + self._manager._plugin_commands[clean] = { + "handler": handler, + "description": description or "Plugin command", + "plugin": self.manifest.name, + } + logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) + # -- context engine registration ----------------------------------------- def register_context_engine(self, engine) -> None: @@ -323,6 +371,7 @@ class PluginManager: self._plugin_tool_names: Set[str] = set() self._cli_commands: Dict[str, dict] = {} self._context_engine = None # Set by a plugin via register_context_engine() + self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins self._discovered: bool = False self._cli_ref = None # Set by CLI after plugin discovery # Plugin skill registry: qualified name → metadata dict. @@ -485,6 +534,10 @@ class PluginManager: for h in p.hooks_registered } ) + loaded.commands_registered = [ + c for c in self._plugin_commands + if self._plugin_commands[c].get("plugin") == manifest.name + ] loaded.enabled = True except Exception as exc: @@ -598,6 +651,7 @@ class PluginManager: "enabled": loaded.enabled, "tools": len(loaded.tools_registered), "hooks": len(loaded.hooks_registered), + "commands": len(loaded.commands_registered), "error": loaded.error, } ) @@ -699,6 +753,20 @@ def get_plugin_context_engine(): return get_plugin_manager()._context_engine +def get_plugin_command_handler(name: str) -> Optional[Callable]: + """Return the handler for a plugin-registered slash command, or ``None``.""" + entry = get_plugin_manager()._plugin_commands.get(name) + return entry["handler"] if entry else None + + +def get_plugin_commands() -> Dict[str, dict]: + """Return the full plugin commands dict (name → {handler, description, plugin}). + + Safe to call before discovery — returns an empty dict if no plugins loaded. + """ + return get_plugin_manager()._plugin_commands + + def get_plugin_toolsets() -> List[tuple]: """Return plugin toolsets as ``(key, label, description)`` tuples. diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 7be1be617..acc63e906 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -18,6 +18,8 @@ from hermes_cli.plugins import ( PluginManager, PluginManifest, get_plugin_manager, + get_plugin_command_handler, + get_plugin_commands, get_pre_tool_call_block_message, discover_plugins, invoke_hook, @@ -605,7 +607,160 @@ class TestPreLlmCallTargetRouting: assert "plain text C" in _plugin_user_context -# NOTE: TestPluginCommands removed – register_command() was never implemented -# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands, -# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS -# integration — all of which are unimplemented features. +# ── TestPluginCommands ──────────────────────────────────────────────────── + + +class TestPluginCommands: + """Tests for plugin slash command registration via register_command().""" + + def test_register_command_basic(self): + """register_command() stores handler, description, and plugin name.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + handler = lambda args: f"echo {args}" + ctx.register_command("mycmd", handler, description="My custom command") + + assert "mycmd" in mgr._plugin_commands + entry = mgr._plugin_commands["mycmd"] + assert entry["handler"] is handler + assert entry["description"] == "My custom command" + assert entry["plugin"] == "test-plugin" + + def test_register_command_normalizes_name(self): + """Names are lowercased, stripped, and leading slashes removed.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("/MyCmd ", lambda a: a, description="test") + assert "mycmd" in mgr._plugin_commands + assert "/MyCmd " not in mgr._plugin_commands + + def test_register_command_empty_name_rejected(self, caplog): + """Empty name after normalization is rejected with a warning.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with caplog.at_level(logging.WARNING): + ctx.register_command("", lambda a: a) + assert len(mgr._plugin_commands) == 0 + assert "empty name" in caplog.text + + def test_register_command_builtin_conflict_rejected(self, caplog): + """Commands that conflict with built-in names are rejected.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with caplog.at_level(logging.WARNING): + ctx.register_command("help", lambda a: a) + assert "help" not in mgr._plugin_commands + assert "conflicts" in caplog.text.lower() + + def test_register_command_default_description(self): + """Missing description defaults to 'Plugin command'.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("status-cmd", lambda a: a) + assert mgr._plugin_commands["status-cmd"]["description"] == "Plugin command" + + def test_get_plugin_command_handler_found(self): + """get_plugin_command_handler() returns the handler for a registered command.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + handler = lambda args: f"result: {args}" + ctx.register_command("mycmd", handler, description="test") + + with patch("hermes_cli.plugins._plugin_manager", mgr): + result = get_plugin_command_handler("mycmd") + assert result is handler + + def test_get_plugin_command_handler_not_found(self): + """get_plugin_command_handler() returns None for unregistered commands.""" + mgr = PluginManager() + with patch("hermes_cli.plugins._plugin_manager", mgr): + assert get_plugin_command_handler("nonexistent") is None + + def test_get_plugin_commands_returns_dict(self): + """get_plugin_commands() returns the full commands dict.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + ctx.register_command("cmd-a", lambda a: a, description="A") + ctx.register_command("cmd-b", lambda a: a, description="B") + + with patch("hermes_cli.plugins._plugin_manager", mgr): + cmds = get_plugin_commands() + assert "cmd-a" in cmds + assert "cmd-b" in cmds + assert cmds["cmd-a"]["description"] == "A" + + def test_commands_tracked_on_loaded_plugin(self, tmp_path, monkeypatch): + """Commands registered during discover_and_load() are tracked on LoadedPlugin.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, "cmd-plugin", + register_body=( + 'ctx.register_command("mycmd", lambda a: "ok", description="Test")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["cmd-plugin"] + assert loaded.enabled + assert "mycmd" in loaded.commands_registered + + def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch): + """list_plugins() includes command count.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, "cmd-plugin", + register_body=( + 'ctx.register_command("mycmd", lambda a: "ok", description="Test")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + info = mgr.list_plugins() + assert len(info) == 1 + assert info[0]["commands"] == 1 + + def test_handler_receives_raw_args(self): + """The handler is called with the raw argument string.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + received = [] + ctx.register_command("echo", lambda args: received.append(args) or "ok") + + handler = mgr._plugin_commands["echo"]["handler"] + handler("hello world") + assert received == ["hello world"] + + def test_multiple_plugins_register_different_commands(self): + """Multiple plugins can each register their own commands.""" + mgr = PluginManager() + + for plugin_name, cmd_name in [("plugin-a", "cmd-a"), ("plugin-b", "cmd-b")]: + manifest = PluginManifest(name=plugin_name, source="user") + ctx = PluginContext(manifest, mgr) + ctx.register_command(cmd_name, lambda a: a, description=f"From {plugin_name}") + + assert "cmd-a" in mgr._plugin_commands + assert "cmd-b" in mgr._plugin_commands + assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a" + assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b" From fb903b8f08c8c9df2b4d8e0175d50eb888de62f8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:55:25 -0700 Subject: [PATCH 267/849] docs: document register_command() for plugin slash commands (#10671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement register_command() on plugin context Complete the half-built plugin slash command system. The dispatch code in cli.py and gateway/run.py already called get_plugin_command_handler() but the registration side was never implemented. Changes: - Add register_command() to PluginContext — stores handler, description, and plugin name; normalizes names; rejects conflicts with built-in commands - Add _plugin_commands dict to PluginManager - Add commands_registered tracking on LoadedPlugin - Add get_plugin_command_handler() and get_plugin_commands() module-level convenience functions - Fix commands.py to use actual plugin description in Telegram bot menu (was hardcoded 'Plugin command') - Add plugin commands to SlashCommandCompleter autocomplete - Show command count in /plugins display - 12 new tests covering registration, conflict detection, normalization, handler dispatch, and introspection Closes #10495 * docs: add register_command() to plugin guides - Build a Plugin guide: new 'Register slash commands' section with full API reference, comparison table vs register_cli_command(), sync/async examples, and conflict protection docs - Features/Plugins page: add slash commands to capabilities table and plugin types summary --- website/docs/guides/build-a-hermes-plugin.md | 53 +++++++++++++++++++- website/docs/user-guide/features/plugins.md | 3 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index aed218ff8..e8611197a 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -561,8 +561,59 @@ After registration, users can run `hermes my-plugin status`, `hermes my-plugin c **Active-provider gating:** Memory plugin CLI commands only appear when their provider is the active `memory.provider` in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output. +### Register slash commands + +Plugins can register in-session slash commands — commands users type during a conversation (like `/lcm status` or `/ping`). These work in both CLI and gateway (Telegram, Discord, etc.). + +```python +def _handle_status(raw_args: str) -> str: + """Handler for /mystatus — called with everything after the command name.""" + if raw_args.strip() == "help": + return "Usage: /mystatus [help|check]" + return "Plugin status: all systems nominal" + +def register(ctx): + ctx.register_command( + "mystatus", + handler=_handle_status, + description="Show plugin status", + ) +``` + +After registration, users can type `/mystatus` in any session. The command appears in autocomplete, `/help` output, and the Telegram bot menu. + +**Signature:** `ctx.register_command(name: str, handler: Callable, description: str = "")` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | `str` | Command name without the leading slash (e.g. `"lcm"`, `"mystatus"`) | +| `handler` | `Callable[[str], str \| None]` | Called with the raw argument string. May also be `async`. | +| `description` | `str` | Shown in `/help`, autocomplete, and Telegram bot menu | + +**Key differences from `register_cli_command()`:** + +| | `register_command()` | `register_cli_command()` | +|---|---|---| +| Invoked as | `/name` in a session | `hermes name` in a terminal | +| Where it works | CLI sessions, Telegram, Discord, etc. | Terminal only | +| Handler receives | Raw args string | argparse `Namespace` | +| Use case | Diagnostics, status, quick actions | Complex subcommand trees, setup wizards | + +**Conflict protection:** If a plugin tries to register a name that conflicts with a built-in command (`help`, `model`, `new`, etc.), the registration is silently rejected with a log warning. Built-in commands always take precedence. + +**Async handlers:** The gateway dispatch automatically detects and awaits async handlers, so you can use either sync or async functions: + +```python +async def _handle_check(raw_args: str) -> str: + result = await some_async_operation() + return f"Check result: {result}" + +def register(ctx): + ctx.register_command("check", handler=_handle_check, description="Run async check") +``` + :::tip -This guide covers **general plugins** (tools, hooks, CLI commands). For specialized plugin types, see: +This guide covers **general plugins** (tools, hooks, slash commands, CLI commands). For specialized plugin types, see: - [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — cross-session knowledge backends - [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) — alternative context management strategies ::: diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index e5e99a463..bcc927bb4 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -83,6 +83,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable |-----------|-----| | Add tools | `ctx.register_tool(name, schema, handler)` | | Add hooks | `ctx.register_hook("post_tool_call", callback)` | +| Add slash commands | `ctx.register_command(name, handler, description)` — adds `/name` in CLI and gateway sessions | | Add CLI commands | `ctx.register_cli_command(name, help, setup_fn, handler_fn)` — adds `hermes ` | | Inject messages | `ctx.inject_message(content, role="user")` — see [Injecting Messages](#injecting-messages) | | Ship data files | `Path(__file__).parent / "data" / "file.yaml"` | @@ -117,7 +118,7 @@ Hermes has three kinds of plugins: | Type | What it does | Selection | Location | |------|-------------|-----------|----------| -| **General plugins** | Add tools, hooks, CLI commands | Multi-select (enable/disable) | `~/.hermes/plugins/` | +| **General plugins** | Add tools, hooks, slash commands, CLI commands | Multi-select (enable/disable) | `~/.hermes/plugins/` | | **Memory providers** | Replace or augment built-in memory | Single-select (one active) | `plugins/memory/` | | **Context engines** | Replace the built-in context compressor | Single-select (one active) | `plugins/context_engine/` | From 139b9ae1e3e093400439ee0dd2c220510ebb7991 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 15 Apr 2026 23:09:42 -0400 Subject: [PATCH 268/849] feat: add vercel deployment, remove old landing page --- .github/workflows/deploy-site.yml | 25 +- landingpage/apple-touch-icon.png | Bin 28150 -> 0 bytes landingpage/favicon-16x16.png | Bin 870 -> 0 bytes landingpage/favicon-32x32.png | Bin 2511 -> 0 bytes landingpage/favicon.ico | Bin 8139 -> 0 bytes landingpage/hermes-agent-banner.png | Bin 12333 -> 0 bytes landingpage/icon-192.png | Bin 29805 -> 0 bytes landingpage/icon-512.png | Bin 137587 -> 0 bytes landingpage/index.html | 665 --------------- landingpage/nous-logo.png | Bin 20988 -> 0 bytes landingpage/script.js | 521 ------------ landingpage/style.css | 1178 --------------------------- 12 files changed, 11 insertions(+), 2378 deletions(-) delete mode 100644 landingpage/apple-touch-icon.png delete mode 100644 landingpage/favicon-16x16.png delete mode 100644 landingpage/favicon-32x32.png delete mode 100644 landingpage/favicon.ico delete mode 100644 landingpage/hermes-agent-banner.png delete mode 100644 landingpage/icon-192.png delete mode 100644 landingpage/icon-512.png delete mode 100644 landingpage/index.html delete mode 100644 landingpage/nous-logo.png delete mode 100644 landingpage/script.js delete mode 100644 landingpage/style.css diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 480b236f8..44da745b9 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -1,11 +1,12 @@ name: Deploy Site on: + release: + types: [published] push: branches: [main] paths: - 'website/**' - - 'landingpage/**' - 'skills/**' - 'optional-skills/**' - '.github/workflows/deploy-site.yml' @@ -20,8 +21,14 @@ concurrency: cancel-in-progress: false jobs: - build-and-deploy: - # Only run on the upstream repository, not on forks + deploy-vercel: + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - name: Trigger Vercel Deploy + run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" + + deploy-docs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest environment: @@ -62,20 +69,10 @@ jobs: run: npm run build working-directory: website - - name: Stage deployment - run: | - mkdir -p _site/docs - # Landing page at root - cp -r landingpage/* _site/ - # Docusaurus at /docs/ - cp -r website/build/* _site/docs/ - # CNAME so GitHub Pages keeps the custom domain between deploys - echo "hermes-agent.nousresearch.com" > _site/CNAME - - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: - path: _site + path: website/build - name: Deploy to GitHub Pages id: deploy diff --git a/landingpage/apple-touch-icon.png b/landingpage/apple-touch-icon.png deleted file mode 100644 index c5da175f8eb397b579c00678b7687bfd930cdc78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28150 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 Oy6?^MDf-_L(Ek87?}i%y diff --git a/landingpage/hermes-agent-banner.png b/landingpage/hermes-agent-banner.png deleted file mode 100644 index 2c4a160ceb721402e21ae107cbea45bbd80702b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12333 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�!X5p5sf&LloNSTYkUfT-^- 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

ML+Q_oprI)7p(3Ki8 zKK_w2qy8)#>u}zpJ@`>{pr9W?oBsahMq(XC?i9>0$Qczbsm%wxtvFmjo!W}uM;7;tIbR+rQNgjXwkAUeu4w=A^Uy)P z4i#lI2z=%MO9c-189$!xx`_cHrtU4|lUkum$lN0XW|*jDdMfR1F1`g1ShMoHTJ+ag zHu;syavhf?t-WL!ztFlcsjQ-KSfiiG3E!+<2o4LUt_Eh9q-khMqw{j0%r9-~HU|C& zK#SJt?(G%n#Ob2FON@!>0NX$^>)5$fthBZRvBdg()aGyYEcm4O`ucd1u*+054#^cI2zHjfB&K}3;x<*H_Bjx;U zK7Ej+YMjaLTYJg@bjuG$Aq+Q1g~iaw-Uga`a{u_XhZ+D@`%FMg5Bj_7y@}4#?{DCS z?CNtxJu(fHQ3L4e1juu1b8+g34HEcOA%a~2r*1|l4sAcWCXrzFEzFnL-ek%M!HO9C zO_V;;N{jP7A7$f0*bM4cu1_tJ%LK4T1~44APuR z$f#6D!`?fR7~Xb$8_d>qa^6;sEX%jP6o?;X_4PCB54#ZA>Q9jg&Qn%~c6nyKf~k3# z)NwhBBkbT8Mr4aJY%LkvS~U~#+N8y}3~t)pWxU8-#wX9lM0B)qmb!R>_bJ5p^V z!DqX>eNzE}f>SwrHD3qVccYP(cf(}=|M=CvwYO;@aaTtPSVW|f% zi2&&9rr8)1TLwciN3rJb8cJQa*q{F86_pHkC)B4OKe>coNY!WvVoA@}I>{M3v0LiF zK)4KoP{{{DGmK2muvSN=k$FsQCIFoTmqh{}`w)K(|E-2X`nRb_S}Q9Zdwv5e=+y zPnocn{#3I9;|Lmn?%WUZH}@fqDl#}zF|pJL_*W<2aK8TXE( zZgqD?iXE}-INMK3*J&BkI2Ls8F!6;k-|dYA9I3Kg?|;<@YH&~Nxt(oFa_ty{qP&N& z8|{SWi^uC!=!Hp$>4aW#ai#OR;=L_5d||m8vb!7-KDZ8Mc0hj#0m!4RD@%#oVK$c) ztPhap(7W%`A|ywvay;9c+fv#Dyjuk2_GCv*SK^|#JM=)n$j8T@ganr3s6gl!HD?~l zssw)SdXU#Wt+-l}0XR6*U7yU#jelx`9h18zZO4J5XSuiY80Pf&0-{{|q69X%=^nEbp(r@7qwS6(I!lIIL;#3!3Nw8zX&ey8uH zbfJsF_*D+>9^FlkZxdfGvenZ8~AX~md*>pY>Y zQ-fc=CF=-^1O-QQmw(+cL~KTYYj1?%vT5c| zy@W3?ILh>kM&0Xp6dF!d+Ko%+b^bFTdwR7^FiDF9sq`Cl0$(8HCIJ^fS|_{~FF>@u z@7&rE{P@t|$^NOm3vplm@m6c=zM(8z4nAJOO2CQ4hkoCSh~e6;i4VhuyQsPv&*sx; z?faO}6A6?{!Dqra90Y?@6ag3#DdP}JHEDt?rr&-cL=Mj=_z%v{Nu+?w@B8w(HH~^| z`3PG_ocLj!QK$%ht)!3wuf<7K5FW3-5-E z_XH3mY1*fS5LOmL2U8xw>_A;lZ`H#XP-ObUj~1KO)j+SIrsk~vvT?uC{01!!n94wf zdrdmIzBCv8zoy!MI)^roswh(tjr?1Y{9J0s)87_mJOyN1tVu~p0X5KRx*B0McT6DV z^ArK#eG$t(lQijgc^POd;5h{}f`Ek`EHDLaJG52FP2Jk$LZQF6P&=MfnJ=m)76pu> zNR3@xxdsNBy_#OjM#sj>y!GU7y}5M&vJgV*hpV=oiErotmhQ;iadIFZKL^`?<9KEm z*&h7sHI3-KfzEzXlugK+ZCHEE34Va@Mjx)$=d?Qe@1ZA}JJxPJjI+Pph)FC!f7(?c zS`E@JkS$3E7dI`L{>WI&u)dPt#V*ce$QHy-$*TaVslAeTBDcNB7F=}iL&Xv110XY5 zJG+ef<3*-P5PV7gG}?)W7iyDpL;tm)z)Xh#ur(tbdKk8)j*L~+tS;Guo*ewVyw_~& zC8FkkHP+MTJYw-qO;`LzZ2?+VlFRWBnH|J&JQu9ZW-wgR($S&kXUEcsYk~`w$>3Gb zw)y zgn&#AeXE5i=xAEWBK>46^JGG@_s}tL^76WQ=$V?%2*Zi~imT?r=!ue0lH_x9bMItO zVvQpn)oKjn4wPUK3yO>BTxYKzS=+aeh1jrf|N7oeDMEsW{-)ypgdNSW6~V7?6!)j@ zIQ~142frqOpRD(4jr#NLxUDfIT*zNL0(lTvWe;Ya<(XGnm2^wc-WtsBJ#l%~BD(s%_L zt`|^HU`-(;jJzK>ap_$lT7cmj%x7O5W=WmpdK#qd{7{lz_bkTLc=FgUbdheyg{Oh< z@9!Ue0~bVl9#*e_Ljx|&@s*&61)#6i=PYq#d}t7pUbw1?3y*Wh_uT)`|MjTUD^t$+ zBEoLRraTUk=^hAnu$y~yX%Nqh^$taxeg5((?BKvLaI}{qxK`YlT@Fmv5v&-$*-}qJ1HuhV zX*lmP_iLbpgqwmcD>!Za3p$;D9i-i6F3cyL_k{m!p$2a`=>AIze3w!4*ia6cb&2F8 z+5}f$3=&QpaKIG|{8x`VY3ycs{Pt}fbvNYIf(_Ko#=(zS#Myw{az13+^>Agc-jh70 zulL^C*f=MDfjoMAG1QjSwlmmZ;{e-UtgANZ!% zxnug>R%7j3gIXWByeiCj-OvucfSKk##a#vtj?t#`@Lf|dkQVmZ872DR^u*uDv;wT^Z5&uNHht_N58^nO)*2-+vIylw?*Cc2K zBjBV@xH@&T2Qh;aubc)A7gOhNA8FH{YErnti7OAGJ24TezmLFNN2)Q z9x<1b_A;EKP z#jo1hYw2%)K`~9#d)>qL$V=T=os{K|B7%b^(kd#L6B85q6}seF zYG2HYgBKBvWfY+&UQ>%|;J5<1_)r{OW{RLN1}M%)LN+2L2jb$nyu^<{3tO0_1chKw za!+mQ(MaRbPLAemLaP{X)xJ52l-I`4K*tya(O35&lgsenG2#mOW!+1Fo_wBo|1 zYXXJ$>NL?W86F-d-hXD*R#sLL4orE{C}LzH0i+(VlmsKXgf~Y@$|fOf+YW9GLxMZP zkm*6Xzp@KHR;b!;k_lA1K~b7ypgL_#*v5XOJ9`nOu2yL^HNx2DF>TsD0*v;LeT3?h z@$j>2YDgcidnL>Vx7eK#(FCUSm2>ZwijW{Xe}Df6kxaInJ@kA&FgrIt3R~)R zf(9}Mz=8}Cp9(j>-^Gdon1JwpO(u)k%6xFn-~lF2eM2EY9R=B7dOkkw>(Chpafsyp zJI}}Z@mq_Gy~u5$yct@7Q4yohGzb_bWQI!>p!z!hyQ?x^2Kb4iLqkLPN4fsA$Q2b9 z(lCpV6M==K$M%Qd5IzCHdd6Tu8bV^AWbGQxDQ?449DWU{IbX58=_r|x_BG8Fc^DcV z3>$X3dMOu><$gSFR5%QJk$I(PL`Gh0IX7LC3KF>q&tVww@8?9NYu6x?zx4I%OMdPk zS`7c?^9FFaXWA|Z+d6|K0;z75Kcd7mj?|AyB7T&o2+*|vP`T2V|9$j5%5vpEIc}G6 za#Q;N4(c5xAL&0PCx_lQ`^ziL@nbCdjDQ%ri!8!;2B%qQhaMUWXB1;FfWMZP!eH^_ zP(g#?>p<#L9qI3fXchVtIm&Q114-E_m_6mZee9EZk8xap_pP@ueQlTXOz;o7U(Y=m zlfgCzv-A3=);n+(rd8tRou1wpY~d{}l`;pwj2*LQP7dhq!W!JZUr%SZb%JdAKN=ZP z3=;bR@k#0wOmmF`z0ttn@9q&kY=IO7K<`ltMt6=yk6g;n7FX?8)8Bt zI%bimwrT6V!H)FFSMDG0a6eI$f8vXO?gRrX5xB--0IVs9S~sK*zWaf>kKh&!t#w+2 zQ<)rVP$G6eB&#=vv~wW?Dp530*Qg$RJjeglVrz*qwNJpRRNKk&$I%}+fO z*z#y}yrGtu7%TzfsHjymth`!ns74;@$&4i*kUlBGM4a2;WBRX0gC4{7GRc~Ok_F(D z$v%{JGO*0)KSIM90kj!BnYuxHFrvEp&Hc$kB6vtcz$ES95C4&MD8lH)6*aHN&)d7} zORC>@@?KDn$4*2R`>w1K$?)I%unb)efM8xwQ-&rOrjLJ(lvtM3W=~gi7?== zUdLD{D6X#IJ0VJ@wO`V#)TDz;JPw$}L?U~#e04uT z?;a5uzSeTJ)Wb6>4zdrd9Tzjp!3PJ1z^wJ5^iPxA7iA`pjHn#rY(9mVpC2uf1FX@C zRbFGZ`R&~n_I2RTMrSI_hr_|<%*&j8RG1#ICBV+yE0TX1HCw$5B8rEEMrTW5 zdA4qxS6r9<7rwj91$%Z@$AFy3-sk?xw>lOVnbQi2Ld+AcS&U(X*P8o#yKkrj-IY{=l zADZytotQZ^ik_zq?a%@Y*?TsGCrNDhVLc25io1wPwgYK!xuq9f3H+FVI%y2-{@joMV&>;(#lF?khyh+j<_~;FEo#6*9vMkj6yT>j zWIYQ^brFby9$N%}s)~8&X~Qm+$NPn~KS@TpMC+x0W;uha+y*{#0=(&S)Ed+aLP6h$ z3mLvJ1OX%42Gakczi)6*ep^-Xw^rU_Yw4MyTY=D@Mr#iQE#kkhuZKhS(@&ntaBwtS zd4=^PjEp_;ujkCGB)NA-eC(aOZ}Y$^dyE&n$A4@M_a;iP_W*5x>m-}NI*^r{gBp!` zi~}e6H+*X)m^l+yR{?PvX4Cer;Y?B2m&eu`tE>2s`4??DULa^)9S(TYv=R~~qUb*( z{u)f_mi6@w$pWg(5EDKa2;u?|D)4*iveKfUVn&q56tH*_q!55-DxuY;Rmu17A#0Ws zFa{}B%@qV?{u+0Z=Rn8H|3CjCvr7tn(F z=gaw4zOAn8qDUJhxVFl+Nwe9B{PzkW5fRbX%HSsB3(ru66!8h5Rvh~?HZ!M<8LBDgsS0d9_cu#DK5oJotcKS#gO|8ePtnw zf|;%YQ`)5=8U@It?SKu`gLuJMay!s&&6dRW(2_#&`R5k&FZ)%nFHRv6i1Fe@KiFLo z+@9}-<$66G{1Vm2YZQ;_XXvv3ZGN27@@&8(x2^s|PEb~aKq4DZ5+n%<0lvq6M&}&I zJNm-+E?Kwf9X2$aoE;Nd1VFaH+U=a{j|cKau%;*fG(V0>wfpj~W;6OEhEOBpztgCI_Oo5 z;XkJ0d!dVA8r1lOk!<1h(wj3Ngb(L)cp_EC-pj?JwXfiP+nhcn4sCjQ6((P%mpMiO{y#|SYyejP$T-8S2Cj4f+9*p-qu`ScQW0~)3PHj*EEi{#4s z6L8dh98T4K{sjH^a_qN(P8cuJ@eIPhDtxKgD01*xF$qHw^^Ic_YjH ziRw#Ul!X|GbvgtNP=^v`8unt{pLpVhi2(FN&mxsfxKtks`#ZC?dM_$BGbjDk|tMc6Z?=K63`KHl2MFh93 zJUsY>85yA`hZGa=@PvefW&q(uL+6gq1%>l)#HH|k7I|L#%%}#MIJE8rKZepk)j9SG zAP&DCDA#kpc!6-8rD5Mm(x0baUW=>m2<)B&nv(q#4`x*>3UL(R7$l`RP$}WURd+5} zyyUk9>(`qK>Ix`Y>cYlo-SK>b&zG9~j#Va(T9>IcJsAgU9hGXcb^>y$zD*yE92^YI zEA6*eoaKG5QZ(G8PLPE@-6&ARMU6>LO;4ZJ6zM$(($YjVv^%qeOa%2(Jken`m8ZU# zFm2mN`A-J|(XH3)hS3bP2X1SpXp``IbGjQjuivYlub>z$g7+SY#jH)PqJomrx;DOF z8EG&N|AmK#ZlgOXq~za@vJk?7@7RktoZ&2E8@J!c5*3UeoqZNuwJb3$Izz_R?OsVC z^FrQZM4IK5RH|QC89YA7uP`_9H07aMV1NuB9PaAudICH|KIMmcmPf(W!z&)y-qsZi z3fxienW@>5A@y;tRuWmf4G|~X5_#WT-c5fBRdM}G0Kv5OZZTH=Ym>|^P@{9g+8B}; zusiY1*6H%(tNlxAS?9p=Klz#%lDtY**~8OELB=b5W#8|@=(XT zDHFh3|)JZI-vTMn`~tH~|)ci?bqvYlSFbyWA_9IE))cNU&6o?ncXM0qYr-ugcA8{H!dahdGh`>;8hOBY zdYozF3cMG*-_4W|8p4AvCP|2l|0Df6NKAuN27jaYV(54nz^h!{AoCGCkfSx4SmFq@ zI4whJ1L!)C#15{L0NCa)wXM5LKlg>d74hliS1~K7EUzwH-eo65;knmqQ zBQ-_y5;mLZu%k4Gc(R!<;-WATjImeKeLMtF-PH?U5v>u@d6|U{AzBVha*C#+1eE5bTY8U2b)+7oWCzD zi54Z9Y)k>AHu~FOYH6qx)W)MMk)`z4#5z;(h_nrJ;XeLcW*CiHcbCfLy{;MbYU5ae z%oI*XMq5YQq6;Y}b)sMU#}Wyuu<95|7vKk-oXS&&*kOPzGc1W#rv9&b1p2Hod>J!> zy`SY}Lw*jmp7i{j*Bll10wDdXJT9o$@;osB*h59ktR zM}04rqN7E_AwW$3Dxp2cFJb-RYRyFxZZ!XmhH(NTV1!$k{jrzToI5kVD-sr=rEFp+ zsd9+-7PdIJ+1}fG*~TLcS1oXo%C>;pW7mf1{_x2@D&@b_g66MyV6g&-^SvH3CW%Eb zCgTUpLaGSKJ+eDRX+|v7nAR2tx?>rf?<%X!&c{DoY>AUWFkapQSw&rSHh?cs40}Y| zRF4P`A73WX7VoaSwwwt3|Lz0X3TN39)C7kJ2?$L4C%%DuNLPCZyxda@v853q_#Snk z#mI#kg~R{F{_`bwjGAr)a_-b$!1ki{Vx61xl~I7v7n@on?(>ZqlRzN1;xiPk(8OZm z7l4=a<EV`|$%Ae&yZe%2YG0lIY{O>aD+Fk*~l2J?6?e!h_8%N(hrG5g81eSFxfV z?%+>Gg(k?Q%I~Oy?s30aSwTs8vpc1Zdq3jZKs+&DeC2a~l2r43G@9fE->q72wY^FO z8fHOTDyq7g+rtaTR4CjfRICkH6Xfje(f0QCtDF|~vw(ij`!49I3tWKVjV9|!r7&jb z@Rip+#;wHM2W#p_gV%lZoh*W!&&FtoG{(v#)l(Q|er_=uh?s|dkm7|dI~yk=_xCHh zG#OGhHda-EmF~kwFn++b$ZFnB$e!iTd70 zw)H)=cv)N_p+uodT8P5s71l7YT6~aFgDyKnn$d`h=fmd*`(yI)^Mj7N%xXmCv0;@~ z8izq-WTY>^&?AC9(dyAR?P!8eQY-yw{^HcFxBt*x;|l+qW=Ev7crhQcG<5MBvm-hn zk6>s@0|HLLvWlqk?Ud4c-IVw4Mxcg#y#>r{BXc%qehBXU!%#!9X$fXzWa{@U;`b*3 zLk|Z$_q%FAonluDVGiZ3NHY&Nz6X4P&T%9{Z~$7=K4CRj$Uqc74k;!T6Dc@nIwfT?cz)`pFP4Zou|EB+^kbG)9bhU<-J^!HY=q+$~#96MNWgB zuGIViT2b(DYVzdK&&_4yqx{h{`I?TxXXB&b)BV{gsAqxS zd;8`@{yrJ&q`PPn8k?=nK;e4aS@RL?K+DH#FUo`84L+0Bj)ba(*>eU>+xK$BeGemN z!aFaCB}m_hG-aTLXMGFR zKX}^VkLfra)!;k*RDB24D6OMiZ1B_LlJIz9u+p&_)M5bYMJFmVRbniAGmdlf4O4j| zh;#r8_VvHP3YUQw#b8{F97XBN;&eY~!g)OJeA?S7SASkTSQ*%bl;!(Znd1Ektv6r; z0G&Mj5PYzmzIrLjR<6fL5LH&Lz698-+>(x@j@_mkyka9rBgmhd{7KTQ>Q02{`~oSt zewPegh(NvdxTuJUBoYKy3sK_Glk`!$I4d1*(014<{f>X9-3le|M@f?W?xl)$>y7X# zdoA%IHgA{A-od_dU=?|+I@$iXn-TA8Boqa)QSId}Mig_|CLKa?ZEDc*dAiT53Rol( zt4V_)rjjuHz#X1zWH>82;AHgtoK!=`r3LK?@aFE^)G$XLZ$`UsBF@jhP*16&oSdUtycw*>Emc`^l&)sJ#M2{^;*4ksOb7$oEy3MpUxVnYjxTc=X^kHV-HrE@oCgB zBRcVOf$M|A)^tx8&tY=c5|2*3z-L=u8Jj?kh-T(G^=;9uadDkF8u^>*WbP;>5WG%k zKm#5??mO=jXV5D~qgrS| z1--{RBUm^%h+kvF5s0HN1dWGNONX(GKqJ>22^-H|AG4%uX>(_Q_A(o_4SWPy=`Ol7NxqsygLQ~ zM8t@QzR}?bD?M8$Cus{AdQg#&vZVcGBGKL3)3JZLGFZ{t2}+BG$6e_t0tZ5?gFOS<3WZu2;xJ7zkTDi%dd zfP_2>TvIbBvHfh6(7`+>7uSuqjZ^H1lGgG~SI7I(l&)#0#=(~K$pYO>R_ zx!bQsqRGN0Wkad8$jX{z8U3Y@Q517H-+)&8gKsSZrf#4Ut0w*+OI3r~w)2T1_G4cs z$jE3UXHw>H+500f~-AQV#txE_vk)gVZLSn{(p zbd=LnpXl}L*JHD@C|$hy`@;77doIThS7x{81*f3>Uhw@pDtj920iHFM9sx<}TIgSO zct(dtT;7b#x$9ja1#tZS&@kv#QO|x4AsDtt{4VRB_FDdL$(Q9EsL<2%!(XxXWn0U? znz+;CcJRkJy^n4~pAuXf8bqqBhsiL=`7uCz$&9f(M3TrPEg~W!Ix)6u&L$g?Ce9i% zhpk{Y6?l7!?8C``$b|k?Dwbhnhc$b*1s01UZ8L$WWEB}Gi$RgL0QhtAP*4QHL>jz;CP z@+=D^dZ|ozTjEQU{3pX4;FR%Ij zpE5vM=e)&^Rz@{Pjhkylen!{6;IW|t0KN5Mm&@HGowsHB@qhp7@36OM-xK)%WZl`e z4Kd1uqEL6(?)Th~xBIX;aqR8MlC7{~h1i@}fS$o}m1RGkZCppqP%!St54DrLn2~0t zKrVMZy8aY0zX@kS##@AHAnHY?tfbbZsrE$dCUHk_p%Q-$8n=?NoNV~2&N?YL(d3s> zvX#olfx44?_l+w$S>pS=S?+77*VhG!8tqLzdm6fN1+h(*E0R;|CebwpeCn900-r0${!`n90bLW8_oiWS^f@SU8;R^+nwF$FXBK6nuyj6K-+B22%mA-4hE{gxkgJ3> z9BiDfEmoz6vV6#1#a#>3Ixmm7nS{G8zQ*j`21>jMC3fv;sGq>@HjV{EIuwV9yZ>4M z)5oV_0Ab7u;}zYD2w0`ZJpVIMnApgh))Y4n)!A>>%;1e#CQuxJMY`u_ozyjehwlOA zm{1^9OzC66sS@Dhr)T6t1c8{I*1LnS?m|oOY&vxzPehI!jU6a`?fAC_mHbZyfbtQF z8gnR5hT8GN58sQUqMnXLB6=LQ5b|AjaSp73lW&+rAD{U*1(+>_JTP#+-*b% z8E_DTW>v5!md(MN0X#!w+X5fL3tsLYq6IIFLS&V8^4t)7d~P^ZWH!%^U*h2Mf(s7G zHE6E8fC~;i`^d1HCJ)t9nfXz~ucaKnqnLFyL9C#rLuV4sJPCUThX<6Wa8y6~A13^H z(1I(H_)luWq~LlN4*)jjyPuZ!bOAU5MEnwNBay)LVYLpi&HSQtnuVPWmdWQZJ~hh# z>)=Vti0p6q*kbJ3(j`FG_xLe0j-&?hG4A7;fs`37pq#b~_lXGoGH?(Ufb#i?u97-6 z(NIdD0=Y#XWPG-TRPyG00xIAh`jRpN)v3;aGN#+{HO0d0SoA`+2$%JNZZ|VSUc>hn z=B&+@WKdI^BUK#S=jR5HE1(19j5;F#RSCpUz(>f*UGDn z@*WCFh}YB8z`dORWEH}-a(AS7m)f*eb3C>(t~b`zfHDsQJd6gb zpH1ucht5G={mi*v?IX6giD{a^35T&ej5TzXM@zZQExP_{_@fG0a&I zNJ-iEPH;V}47gKeHEu=#6<{IYK7??_k%|!c9W^k6k76unG`jhiM54hZoGNX%Et2on zVNd=0qU%SPLij<~T`Pd&Hzd{Sv}SS>yLmq< zu?w16)HS&fw;c2oZ)1SCl7^L+CY_cADR{xEB)tKfy8V$l-N?|#+rEl0NXMxnFCznQ z9JKBMy82u=T=J?AAuUYEYPv;24`6LYG-lI+ZDB?8IX63iUKSR83*m|c2S2jAV+q8d z#x48LX>KsNNj}?5^hTwq@-uVE#v=6jFNlM@a{Z<`AMEQPi#omrH(dkP^JwDt`!trLD;hW5{&MfENwk-#17GGN>-{?LY}oSo|&GcdltW zWhN=V^FsKcNIlU?C^`pDTLcGB-Y>{Pa*vYtI|8HQq?oebcbB~GG0=7WAD)trA5 ztyTB>sJZ6nCvC_)d{v3W!-ggZ&Fg)KCnd?^x*}UYLUBD3RL&HPiMk?10z7qgiogxr zk{=F|S@RJC11}fcj$42U90sVJiPit!bH;Zh^CPAtML59KXTOD?x)LF= z?5!ks2TjdMKcNr3-;#37IQ-Feyf9r#UQ zWFeEx@{Sr-aF11fn80<3oYz~7PUkJ^z_WUrD0Zp4xtqg;qA*&}w*wih))L2pP*#8P z$g)GBoVs(phJtTLi<3Ml-2KV9Z!i*3Dr``N2L|cKyW>Q7Y{4-OVrku5JhjG?LgWf;!RJd9Jfys3i!6>@1gXgGGz%Im^NU8%bk)Br zkq10X)?4q6fp)?WI(f3%iO$iPDDe;u#;>d8ZObt)J~4OU4y?;NfsOXVfBKPM8t{WSbQP?9j(^>!X5_-ZWn@Q2fwu1%yv!k7-77;exkb7 zmM6l5=#Q#w$IWqYp9^pE&QIk2kYC^t{e4xx4q$EfVEG5+-HRQD46?X~p+A6NTA@uL-G)K0kQnjJ&_58E>O; zuHFa#_2BEuG!}FUyQF;^umq}PrCRgiVhoi_?0@Ha!(F*UqI^7Pq!! z|B#KN7~k~VIV;c3wWsRH>chLA>lEXuYzFGuMlnPZHC_UEUI(g7NLVU_!4`>PBOLZj zLN64r>kks(JJ}p9_-*r>k8;u;p6|N2m;I=Sn%NIw)gX<@ z?@QkW2u@H``VFT6iXf!Fejz513m}11LpSKxRh}e9C@3n1nvsC5+8TH|_o@gi#sTPf ziab_*y+y2CclJFv?nX;k$K6<2eX2s{c1?nj7?M@cLum8y5b->HQrwKQMuM}`6M}_~ zjzY=BelhF63V^gnUXZIBXwH8>Z&-XFK`;X=m^g9)EM#i;BodUTG#qN@`6dfUaMZP@ zdkdFJ)Fy^p!q_FvY^W5l9s~0qq`e%Cgf?lu1%NU{JP)+6Ms2d)_>(o0yY{YGj#IvT zi82X#NTJpo=N3~U7m2;{UBrq-Vq(B4Y@uAS;?jnTQ*ehVuyqp-rmc4+O5h}a){UMZ z$g=|{Loj)Ey_gP?4hRr)YCGfyDGFMiUmX6CA@$eZ0K?ZsBH+YYQOT%GnprO8)v)ru zDZj~(h4rO$fK1rB4`tVQy=LBd_(q$eT1YL5pZC;b$UZIEjQn-S4P2gaADAL(qnOI$ zNH?Wy3iEV(-qkq!_t8d~o~(3mQuY`wxVqBXCV6s<@v&`q(Jr@^+i@zFpIT6=V2w*9 zDLH}r9UyoB)2MVe&z7R0E&wwEv>u)D`jVaZI=U>IbyCrJD z$60@p^h$_V;<-lzNk;nWfR6_s-DWsiPw2q>!?Cbp=)6#?l-S4}cO;z;y^UQrSg$xY z!RKrXgnOx6>7=5IPCLcf)36r5735bz+wSktjeU0+7hGqupYMNC9#$z&S+D)L<|3jU z?Q(vPj}25EMc5iQmBFK5V~anTA8NmS>v>!IPDP%<`UC2&iC2Tm?QXG5DkSx#Q{6mf z+8|{}jvq58Oq$s;VKcwU{o3DC!G=MnWkFLZjs1CibABwL9>-kmXE@^lCT7~9i`>fH z4eKsowk18|ks?D&L6dvBZ4&qjfTs=F#T+$w#6mtbex{|%{8n~C42P!0H|TMgC|37w zo+#w;L5dq1$8~>tNB!(&as_g#_iVujX>lgwblbgx?N-XoQ4N=jQZgTLTQjUGonMw187Q z_=^~1B^+P1y$_eZ%4g7;mc~P@A?3iKr+Tw*bWlF`TpXeY`81M|-Rto3XA`-rX2mGN ztfpxjJzRR3cv{LV9i}Sk6BvL42sXY!=|Xz+;tXG)sL~Eh$Glqb!%`JpMd*%=b4-=1MdKn-*)G6)g6D&V5T8GuWY|Kf0tcQc zB=KL7B%`LD7B?CHe6@Hyu9;ModG!hjL#A+qNQ1?xj$BbOCR*~%urX>T?8;5ydrw9S z62MxFO-+>q?qOj{%2^RAPfBMNpqX{IhrIpw{>+pFX~EU$+8^oG%6jejnhf?$-?En%xk-0_y*otd1R6eOCSuJ!2teowds z6dI}$7h1R@Hl5sMoS&`YaqnH=-Dqw{S)=0cO4K#ML60yaliU#6g- znMo^@VFQ1V{WtR@v}o6KMH@N~kJPj!4?0~S#7`$G9AxY0C=0fMw}n@P>oGKfHIxQ* z@8kdto8(tEcB06mcj32h5o`=NmD!YS50?gzH}9k1dpw$RMt zBO_X=XfCQH!o;#^h)xWLs3;(+iQgcoir-i=Ff(`2Ih~m&*lQc&o6;6IBQ5#fu>r~7 z3NTScj_&suc`ZeN*TiUXrrH~$t$hD=_SRL+Ve1>T>jgfwClUm>AgJp0B-jEzClv@{ ztGaZW?#dLhD#3}#d@n@P=iiHrl~&kjbJ+IvI0e335LJy zZT2}6S|?3db6k6RerRfHhJoOs?pv2eoR{vYs-7vWue9LDvb)<1ex==94ZV|ufJq;K z(UoC&DIIqrHM^|RH9sx+@e7cJzup&?#{dV>>*Ju@&R?Tqv1@DQLz#T&&mt`Fn^?IZ zt&GmwXHUT$qYf4Pis-TBW>4!Yu$n0TrEyqUF~(kdy05%ZTs; zhH>Es<lazI7sIPV1|HB44 z5z1nNT~8;=d_Md zd~V=9YA=Nw>oYAFQ0DMmoro>Vp&|u&pR^DjormW*{zhBx;0St+|LjS&FlaFM(LE-Q zsgn;beKT8O!jvH7i3G{Pmb5T0<7($-V_@ihhR{O3`aRMkhB$f}8X87rcfZlp{FwDQ zJoNr(93q%snFr~L#6pCuic%=9xS6Lj{rh)D(JSa9@)(Xk#w?aSY7`yw`R_rzcgVNM zNF)%?m-1w8-@TgRvfXcaWsiNpjUhQ2qOynn@9a4{K@qj6om909W^^4{LdT^P2n;Y{ z6lG4hA~SP96Y&N^Xh0^cAdv`E!5F&Pcl-yBdBd@jl zCHnhrl3?~wo`zR)RuSvE0--Y3|0C%v!=h^2EldI@@41n(cHAu})Nx9+e zxHjHyyQF!okS4zYI)fk2=_P1JPhLF;AW7hTYAEsOZ)XnCHS)5>N}ctIC0je75RRs$ z#bSh6{!1WAkqf8YP%H{8nuZx46USe;be%1NSbW)^b!6 zcee9X2S$PgXHR_T_A_`)`w=tjdyyVf1|D&XVrbKnl3*!3cDp?NL_9sRh0i>MHnfS$ zOb2(jlHP#x$tSzdcDrR~P)N*@Vm`t^wPg6cf{Bc%_cJM*^<6DAYQ_Q$6^YX*DyWSk zGl_(c`%(uoO?pjsiBg`VJBK{*-M*7BGyVRno*3-$=Xd}8_n$1<5)R_X!uIRMe!S!s zRwFuMGJ?_kQNB$*`L`CPmnYwB0W`Kou7RVU&5(By3j7;G`Il%CWLuqMd+F;F7xhr+IHInac(RdlDy!Xz3@(XU z*pM7>n2e5&9>U~?qUsFo@0*#OT}*vJ^cw)16nJ!!FmSRAuMNr;p zFF$tK*0Gq7wABMs5`kg}=IIFWgdo>j?Hs{7BkyBea9j%c3hVE_M74V{bN~C4%2CuS zvOi36mHlJxrtuO-;T5@KT@hK`3l0`gO1>WbJoVWyrnq0f6T@$zcdYvvpOjLsSA{XK zmb?dgf{?!B9w&Ra(iIT0o6!ejcgo=0dY5v~b^Amoi3Vc*pie${QVTeQu*- zEVv;E`A?ZCMv(p2jTj-fktTXtpVU3ttMluqCvphrmx}R+LFlfN|D?+XziJq_z|Sv@ z730@Ne5O?zKJS5#jg9?bcyG|#HwfN%!Wg*S&o{A1-mP^{pcikZEfQV*cSMHc$26p- zr}b*m5vE(mEVU*=&GrZX=tlEq49b#w0n!&>4%BXQWfs}NFEzQvs^qI8mWTKc4UosP zUuQ49n2-GfpN@OG;n1B+I%4oD$;7vQyI9&T3Uc9W4B6eFEHhZ?(e*Z`zrR>TUTnTV z1)zp$6JM=jWL%PGF547PUZgiOAE$Kb{=t@aG%z4-L}p+$zxE|AmfS!h+;p7}>X#Vh zvuq`RIc3leY9=w%)D!_+)JRVWV`CNci_|IADNKBFgjh#t8{#lT2L}iH!C$#GVP9w~ z?G}H+ss|~d3{iJJ-hH0 zDP|4}O_^Mxdl}{<927TuA93iG*_gcBX`ZODd^uRo>kRM^WRsMH8J6!cK@c&%o~`ti2DZ_mIVXhJC94Z$^-O0 zCPUjENYK~Zdb4W$7480TUu$cI*DV2%HrFpks@|Z`wT1`Fr!P%R_#LEuc3?s->4Hqf z1qa}50J^1Gm?-lYy3*IXCsxz7pxIf)we}?vFh&ckUH+85LT zP*dDaVevfAzL(1#mJ|gow}$n#t{M@YJE zWt93RY2|*MXJHkBdM+$-{LuR8`jh>*BOm@d`kfi)v8a>U!MDJ%7o3d+?@=6?bns5I z!X90mot%fS;hTSy=r|wR$9Y)eHk09)M;l*>nlSqjkaJ2@67IR{7}%`%IabHdd7U97 z=dKI$J>2#I9Waw6GdrJlab5B*>9h1`z~`wt*mnGsczg*AT`1Ki6qQQ6>=5X*^l$Y+bZ`pWI=#Ru`kGvR zIB8=Q!TVt^`s@pkhi4ug_WMK=rr!~;AG&$5AJO2bZ`S(*fwL(9m1XFs%*jocl2CAQ z);%i#fm}_>{ShhljeC<@x!i={@6WJb)ZC0{m|eY84hcbxVolr4_C+t(s=7cyIhwDi zqhN+#4^&owDF30eph8lfW|!)pnTcYT&*FeH8;F5hQy+;)%*d&SG!pR(yjv1(B=(m- z;4)r=Xq%f1-jG5TDYtrG-hvY=BxJHq)j{EQEYnM)p`qc`$n#mYjKFYfw&W5iU5H9w zRWgn3+91YwvcOxN&sa9%u?x`4F<=% z9;4b`{wV=q1z^i$IC8j+g?%vTg2Oyg&{fr){26GCz_cz1901XwuEhDu#SyU;by~NE+~0eAC!tH*3FAlP$68NHRQd>buc&>Pi2sE@lFWs_ zqy@_DP0^D&oI8AYyBG6E3MtHL#k0C&{ra0R!N)e}5E?vw{)M*;EMlO#^Mx{qqjw!4 zWM{{6dUlpln1$tHg`e!t**}s{zH=2>H+HE|GS{~poSc0d2oQ+88Bl@g0bK)vxC&YD z{sI#X9R`7!xPZ!x*%cI2kP;IUuIf7RP@kLj3$x#&3^lph`y&`09R_pLv?ejt?+{dB zO(Z|n4>H)E9-_7|4th*xsHeVnP{36qRhUJa3x`OHO;Hd78b4lu?3BJqA`68v+WBdwf3*v`(a3Rz^aU@V@u-bsG$yf^*_csyWWZ zO%={mVrr`Y#-u42e4UCYKWy5d@x|;0mR6Tbd3kx=UcJkjJ`1n=EjDICG`EaS!iM?u zaEk!>WH5?AK{^|bDB!m9!Bx>q*`HcD-`3>e04gjFsry_sp^i&dl3w*eV= zGFX~WinCl9A^fQv&=k77f@%&o+}zyS9iO@V_7Zxb#ViqQ>^=_!VhAHv&%L8T&V@dl zR+lRNI#7kA_=`XLvbPsnYW8PiW*>-2G~I9gPGtYq_4F799l91_9WF$`2NOTTiUOG9 z$01l!SZt*YQ*6#dm`sNCGzBAD*n7ZC6k)FIh~aO(MwCv3nH^_Lej3uLF!jb;25a)< zyT3ov2UAmMutPzmXs1h%M8RiVoZt~coDPDUijwqc>>Y*7FKPKC{~{1Az`HK5?hJ7I z`wbsxW<}PfcV{N^Ze89p%tPPW>_U~_HyGIfx%%RgKa}i0R0yPjZTG+ISpL(FVjGPg zlM~L)kAH@9jw9CM@Pc+N0euDtzv?Sdnz`xRZ%5brc|p~v-4|LE<1_-4ul|dW{ad-B z(mEZ>UGw~}AB|jASD?)Iwm^#$aHT`VRPHqjiwaFw7Ox>5Wc1v?P0YY9;~l&A zN&5Ncmm#(~LfLLhCT<0_ZT~jp<@#=sYVA&F!|XYhQQQO);u54du6k}felJW*_?+uG zISF7~011d5k(YCfUxip$bcz{4Q2hF&W=n_+z2${7@r~!{|IH6YYk>O(tY~bOzg`K*#Ba!+fYEDT@3kL`r0elq7Y&|Al z9g$J|taaGXPRO?$TWVZa-s`(AY~P)OYg!1(pVQUC*iSZC(DjJVT~Ju#nElOzFSv9G z%s`w*W$ub*X&Tr|3e0;T&U5db&Bv2e6d50oer`XRh=($SeJBXMHq37}XLIIxQCCFqCsEf| z7Z;b2)jUYS{mWV)#y}VrHAV7|ge2&weAobM!U=9PU|j=60(}N;%#KCBs>wvaAG8PO2_@u6px<$|Q$zp_h*iDp<}2tG4PdkU@cvu!Q$laeXyD z)LPqLnCdyqyzg&bS&~dss{A4z(9)m20Re5@wWYsjh(-NhJxeZDqJ&PoVoS6{^Hx|q zHQ4JBxKzBOum+RK3MYr!6a~R zr?t9f9tjxW0(bF^PR~=9AZUkZQDA5Kc+4JQ^dFbV7N*d|#jhdix@F6+KnnoPUch+* zzTw%B?(7Nda+h1NCIhwD&|*=kngw(gBx%p0B5M%*=#3#=(mO6L{+k=c@_YCoR2+T% z?+cIFZ*B!tLHP&?qBHQ|Xur8Xd^O0~@2smtQr*fLWZ-=CUX44oLXK{ zWspx=3Y8Uqe*`bKwn}6CG>F@`|MK~Bi^Q0A)B062ADPRVKJRBKB&Z=aG!UDn5)hzg z^5z&odLLH9X>9cVcN09_B*JSN@+||yIxzOH7G1A)`4i2aS?l+mz~qGd3W9-mI5_~N z+1-xJ^4KN*kCy@@e zalJFRQdAS2lND?7w*7RAu5ygwt7m3rMh(LJ3p?Lcr=~I~#LMX9Q*!DmWsXy53n@kd zs=iEclY-8QJ{kJVAUR-5_%c@&Ty@CU-e{qCZ9twfkO&^a^Xnzm)r0pghp>I-4GN-2 zAom4+AZ4q-EF!Uz30*=vJu}A;I(8DU1%NA3nZ2b#?{pS)O!lWr+s8)6Hv_7x-i2uK zw@0u_=s!F?Bb=HYHdeYi@)X}JcZKGRPiusUzVUm~3V8

sJu{t`y8DsrS@F4j_h= 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 diff --git a/landingpage/index.html b/landingpage/index.html deleted file mode 100644 index e24ed11c4..000000000 --- a/landingpage/index.html +++ /dev/null @@ -1,665 +0,0 @@ - - - - - - Hermes Agent — An Agent That Grows With You - - - - - - - - - - - - - - - - - - - - - - - -

-
- - - -
-
-
- - Open Source • MIT License -
- - - - -

- An agent that
- grows with you. -

- -

- It's not a coding copilot tethered to an IDE or a chatbot wrapper - around a single API. It's an autonomous agent that - lives on your server, remembers what it learns, and gets more capable - the longer it runs. -

- -
-
-
-
- - - -
-
- -
-
-
- $ - curl -fsSL - https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh - | bash - -
-
-

- Works on Linux, macOS & WSL2 · No prerequisites · Installs - everything automatically -

-
- - -
-
- -
-
-
-

Get started in 60 seconds

-
- -
-
-
1
-
-

Install

-
-
-
- -
- -
-
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
-
-

- Installs uv, Python 3.11, clones the repo, sets up everything. - No sudo needed. -

-
-
- -
-
2
-
-

Configure

-
-
- bash - -
-
# Interactive setup wizard
-hermes setup
-
-# Or choose your model
-hermes model
-
-

- Connect to Nous Portal (OAuth), OpenRouter (API key), or your - own endpoint. -

-
-
- -
-
3
-
-

Start chatting

-
-
- bash - -
-
hermes
-
-

- That's it. Full interactive CLI with tools, memory, and skills. -

-
-
- -
-
4
-
-

- Go multi-platform (optional) -

-
-
- bash - -
-
# Interactive gateway setup wizard
-hermes gateway setup
-
-# Start the messaging gateway
-hermes gateway
-
-# Install as a system service
-hermes gateway install
-
-

- Walk through connecting Telegram, Discord, Slack, or WhatsApp. - Runs as a systemd service. -

-
-
- -
-
5
-
-

Keep it up to date

-
-
- bash - -
-
hermes update
-
-

- Pulls the latest changes and reinstalls dependencies. Run - anytime to get new features and fixes. -

-
-
-
- -
-

- Native Windows support is extremely experimental and unsupported. - Please install - WSL2 - and run Hermes Agent from there. -

-
-
-
- - -
-
-
-

See it in action

-
- -
-
-
- - - -
- hermes -
-
-
-
-
- - -
-
-
-

Features

-
- -
-
-
-
- - - -
-

Lives Where You Do

-
-

- Telegram, Discord, Slack, WhatsApp, and CLI from a single gateway - — start on one, pick up on another. -

-
- -
-
-
- - - - -
-

Grows the Longer It Runs

-
-

- Persistent memory and auto-generated skills — it learns your - projects and never forgets how it solved a problem. -

-
- -
-
-
- - - - -
-

Scheduled Automations

-
-

- Natural language cron scheduling for reports, backups, and - briefings — running unattended through the gateway. -

-
- -
-
-
- - - - - - -
-

Delegates & Parallelizes

-
-

- Isolated subagents with their own conversations, terminals, and - Python RPC scripts for zero-context-cost pipelines. -

-
- -
-
-
- - - - -
-

Real Sandboxing

-
-

- Five backends — local, Docker, SSH, Singularity, Modal — with - container hardening and namespace isolation. -

-
- -
-
-
- - - - - -
-

Full Web & Browser Control

-
-

- Web search, browser automation, vision, image generation, - text-to-speech, and multi-model reasoning. -

-
-
- -
- -
- -
-
-
-

Tools

-

- 40+ built-in — web search, terminal, file system, browser - automation, vision, image generation, text-to-speech, code - execution, subagent delegation, memory, task planning, cron - scheduling, multi-model reasoning, and more. -

-
- -
-

Platforms

-

- Telegram, Discord, Slack, WhatsApp, Signal, Email, and CLI — all - from a single gateway. Connect to - Nous Portal, OpenRouter, or any OpenAI-compatible API. -

-
- -
-

Environments

-

- Run locally, in Docker, over SSH, on Modal, Daytona, or - Singularity. Container hardening with read-only root, dropped - capabilities, and namespace isolation. -

-
- -
-

Skills

-

- 40+ bundled skills covering MLOps, GitHub workflows, research, - and more. The agent creates new skills on the fly and shares - them via the open - agentskills.io - format. Install community skills from - ClawHub, - LobeHub, and GitHub. -

-
- -
-

Research

-

- Batch trajectory generation with parallel workers and - checkpointing. Atropos integration for RL training. Export to - ShareGPT for fine-tuning with trajectory compression. -

-
-
-
-
-
- - - - - - diff --git a/landingpage/nous-logo.png b/landingpage/nous-logo.png deleted file mode 100644 index cfea9a661337855b90209ab3160d8e07a16e183b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20988 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 diff --git a/landingpage/script.js b/landingpage/script.js deleted file mode 100644 index 4cd097bdb..000000000 --- a/landingpage/script.js +++ /dev/null @@ -1,521 +0,0 @@ -// ========================================================================= -// Hermes Agent Landing Page — Interactions -// ========================================================================= - -// --- Platform install commands --- -const PLATFORMS = { - linux: { - command: - "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", - prompt: "$", - note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically", - stepNote: - "Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.", - }, -}; - -function detectPlatform() { - return "linux"; -} - -function switchPlatform(platform) { - const cfg = PLATFORMS[platform]; - if (!cfg) return; - - // Update hero install widget - const commandEl = document.getElementById("install-command"); - const promptEl = document.getElementById("install-prompt"); - const noteEl = document.getElementById("install-note"); - - if (commandEl) commandEl.textContent = cfg.command; - if (promptEl) promptEl.textContent = cfg.prompt; - if (noteEl) noteEl.textContent = cfg.note; - - // Update active tab in hero - document.querySelectorAll(".install-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.platform === platform); - }); - - // Sync the step section tabs too - switchStepPlatform(platform); -} - -function switchStepPlatform(platform) { - const cfg = PLATFORMS[platform]; - if (!cfg) return; - - const commandEl = document.getElementById("step1-command"); - const copyBtn = document.getElementById("step1-copy"); - const noteEl = document.getElementById("step1-note"); - - if (commandEl) commandEl.textContent = cfg.command; - if (copyBtn) copyBtn.setAttribute("data-text", cfg.command); - if (noteEl) noteEl.textContent = cfg.stepNote; - - // Update active tab in step section - document.querySelectorAll(".code-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.platform === platform); - }); -} - -function toggleMobileNav() { - document.getElementById("nav-mobile").classList.toggle("open"); - document.getElementById("nav-hamburger").classList.toggle("open"); -} - -function toggleSpecs() { - const wrapper = document.getElementById("specs-wrapper"); - const btn = document.getElementById("specs-toggle"); - const label = btn.querySelector(".toggle-label"); - const isOpen = wrapper.classList.contains("open"); - - if (isOpen) { - wrapper.style.maxHeight = wrapper.scrollHeight + "px"; - requestAnimationFrame(() => { - wrapper.style.maxHeight = "0"; - }); - wrapper.classList.remove("open"); - btn.classList.remove("open"); - if (label) label.textContent = "More details"; - } else { - wrapper.classList.add("open"); - wrapper.style.maxHeight = wrapper.scrollHeight + "px"; - btn.classList.add("open"); - if (label) label.textContent = "Less"; - wrapper.addEventListener( - "transitionend", - () => { - if (wrapper.classList.contains("open")) { - wrapper.style.maxHeight = "none"; - } - }, - { once: true } - ); - } -} - -// --- Copy to clipboard --- -function copyInstall() { - const text = document.getElementById("install-command").textContent; - navigator.clipboard.writeText(text).then(() => { - const btn = document.querySelector(".install-widget-body .copy-btn"); - const original = btn.querySelector(".copy-text").textContent; - btn.querySelector(".copy-text").textContent = "Copied!"; - btn.style.color = "var(--primary-light)"; - setTimeout(() => { - btn.querySelector(".copy-text").textContent = original; - btn.style.color = ""; - }, 2000); - }); -} - -function copyText(btn) { - const text = btn.getAttribute("data-text"); - navigator.clipboard.writeText(text).then(() => { - const original = btn.textContent; - btn.textContent = "Copied!"; - btn.style.color = "var(--primary-light)"; - setTimeout(() => { - btn.textContent = original; - btn.style.color = ""; - }, 2000); - }); -} - -// --- Scroll-triggered fade-in --- -function initScrollAnimations() { - const elements = document.querySelectorAll( - ".feature-card, .install-step, " + - ".section-header, .terminal-window", - ); - - elements.forEach((el) => el.classList.add("fade-in")); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - // Stagger children within grids - const parent = entry.target.parentElement; - if (parent) { - const siblings = parent.querySelectorAll(".fade-in"); - let idx = Array.from(siblings).indexOf(entry.target); - if (idx < 0) idx = 0; - setTimeout(() => { - entry.target.classList.add("visible"); - }, idx * 60); - } else { - entry.target.classList.add("visible"); - } - observer.unobserve(entry.target); - } - }); - }, - { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }, - ); - - elements.forEach((el) => observer.observe(el)); -} - -// --- Terminal Demo --- -const CURSOR = ''; - -const demoSequence = [ - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "Research the latest approaches to GRPO training and write a summary", - delay: 30, - }, - { type: "pause", ms: 600 }, - { - type: "output", - lines: [ - "", - ' web_search "GRPO reinforcement learning 2026" 1.2s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_extract arxiv.org/abs/2402.03300 3.1s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_search "GRPO vs PPO ablation results" 0.9s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_extract huggingface.co/blog/grpo 2.8s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' write_file ~/research/grpo-summary.md 0.1s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Done! I\'ve written a summary covering:', - "", - ' GRPO\'s group-relative advantage (no critic model needed)', - ' Comparison with PPO/DPO on reasoning benchmarks', - ' Implementation notes for Axolotl and TRL', - "", - 'Saved to ~/research/grpo-summary.md', - ], - }, - { type: "pause", ms: 2500 }, - - { type: "clear" }, - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues", - delay: 30, - }, - { type: "pause", ms: 600 }, - { - type: "output", - lines: [ - "", - ' delegate_task "review PR #42 changes" 2.1s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - ' git diff main..pr-42 0.4s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' patch tools/registry.py 0.1s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' python -m pytest tests/ -x 3.2s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' git commit -m "fix: handle empty tool schemas" 0.3s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Found 2 issues in the PR and fixed both:', - "", - ' Empty tool schema crash in registry.py — added guard', - ' Missing error handling in delegate_tool.py — added try/except', - "", - 'Tests pass. Committed the fix and pushed to the PR branch.', - 'I also saved a skill for this PR review pattern.', - ], - }, - { type: "pause", ms: 2500 }, - - { type: "clear" }, - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "How did we fix that Docker networking issue?", - delay: 35, - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - ' session_search "Docker networking" 1.4s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Found it — from a session on February 12th:', - "", - 'The containers couldn\'t reach each other because the compose', - 'file was using the default bridge network. We switched to a', - 'custom network with driver: overlay, added explicit', - 'aliases, and set dns: 8.8.8.8 as a fallback.', - "", - 'The fix was committed in docker-compose.prod.yml.', - ], - }, - { type: "pause", ms: 3000 }, -]; - -class TerminalDemo { - constructor(container) { - this.container = container; - this.running = false; - this.content = ""; - } - - async start() { - if (this.running) return; - this.running = true; - - while (this.running) { - for (const step of demoSequence) { - if (!this.running) return; - await this.execute(step); - } - this.clear(); - await this.sleep(1000); - } - } - - stop() { - this.running = false; - } - - async execute(step) { - switch (step.type) { - case "prompt": - this.append(`${step.text}`); - break; - case "type": - for (const char of step.text) { - if (!this.running) return; - this.append(`${char}`); - await this.sleep(step.delay || 30); - } - break; - case "output": - for (const line of step.lines) { - if (!this.running) return; - this.append("\n" + line); - await this.sleep(50); - } - break; - case "pause": - await this.sleep(step.ms); - break; - case "clear": - this.clear(); - break; - } - } - - append(html) { - this.content += html; - this.render(); - } - - render() { - this.container.innerHTML = this.content + CURSOR; - this.container.scrollTop = this.container.scrollHeight; - } - - clear() { - this.content = ""; - this.container.innerHTML = ""; - } - - sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} - -// --- Noise Overlay (ported from hermes-chat NoiseOverlay) --- -function initNoiseOverlay() { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - if (typeof THREE === "undefined") return; - - const canvas = document.getElementById("noise-overlay"); - if (!canvas) return; - - const vertexShader = ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `; - - const fragmentShader = ` - uniform vec2 uRes; - uniform float uDpr, uSize, uDensity, uOpacity; - uniform vec3 uColor; - varying vec2 vUv; - - float hash(vec2 p) { - vec3 p3 = fract(vec3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); - } - - void main() { - float n = hash(floor(vUv * uRes / (uSize * uDpr))); - gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity; - } - `; - - function hexToVec3(hex) { - const c = hex.replace("#", ""); - return new THREE.Vector3( - parseInt(c.substring(0, 2), 16) / 255, - parseInt(c.substring(2, 4), 16) / 255, - parseInt(c.substring(4, 6), 16) / 255, - ); - } - - const renderer = new THREE.WebGLRenderer({ - alpha: true, - canvas, - premultipliedAlpha: false, - }); - renderer.setClearColor(0x000000, 0); - - const scene = new THREE.Scene(); - const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - const geo = new THREE.PlaneGeometry(2, 2); - - const mat = new THREE.ShaderMaterial({ - vertexShader, - fragmentShader, - transparent: true, - uniforms: { - uColor: { value: hexToVec3("#8090BB") }, - uDensity: { value: 0.1 }, - uDpr: { value: 1 }, - uOpacity: { value: 0.4 }, - uRes: { value: new THREE.Vector2() }, - uSize: { value: 1.0 }, - }, - }); - - scene.add(new THREE.Mesh(geo, mat)); - - function resize() { - const dpr = window.devicePixelRatio; - const w = window.innerWidth; - const h = window.innerHeight; - renderer.setSize(w, h); - renderer.setPixelRatio(dpr); - mat.uniforms.uRes.value.set(w * dpr, h * dpr); - mat.uniforms.uDpr.value = dpr; - } - - resize(); - window.addEventListener("resize", resize); - - function loop() { - requestAnimationFrame(loop); - renderer.render(scene, camera); - } - loop(); -} - -// --- Initialize --- -document.addEventListener("DOMContentLoaded", () => { - const detectedPlatform = detectPlatform(); - switchPlatform(detectedPlatform); - - initScrollAnimations(); - initNoiseOverlay(); - - const terminalEl = document.getElementById("terminal-demo"); - - if (terminalEl) { - const demo = new TerminalDemo(terminalEl); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - demo.start(); - } else { - demo.stop(); - } - }); - }, - { threshold: 0.3 }, - ); - - observer.observe(document.querySelector(".terminal-window")); - } - - const nav = document.querySelector(".nav"); - let ticking = false; - window.addEventListener("scroll", () => { - if (!ticking) { - requestAnimationFrame(() => { - if (window.scrollY > 50) { - nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)"; - } else { - nav.style.borderBottomColor = ""; - } - ticking = false; - }); - ticking = true; - } - }); -}); diff --git a/landingpage/style.css b/landingpage/style.css deleted file mode 100644 index 30334df0d..000000000 --- a/landingpage/style.css +++ /dev/null @@ -1,1178 +0,0 @@ -/* ========================================================================= - Hermes Agent Landing Page - Colors: Nous Blue (#3050FF) palette - ========================================================================= */ - -/* --- Reset & Base --- */ -*, *::before, *::after { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --primary: #3050FF; - --primary-light: #5070FF; - --primary-dim: #2040CC; - --primary-dark: #1E30AA; - --bg: #0A0E1A; - --bg-card: #12182A; - --bg-card-hover: #1A2240; - --border: rgba(48, 80, 255, 0.1); - --border-hover: rgba(48, 80, 255, 0.22); - --text: #E8ECFF; - --text-dim: #8090BB; - --text-muted: #506090; - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - --container: 1080px; - --radius: 12px; - --radius-sm: 8px; - - --ease-in-quad: cubic-bezier(.55, .085, .68, .53); - --ease-in-cubic: cubic-bezier(.550, .055, .675, .19); - --ease-in-quart: cubic-bezier(.895, .03, .685, .22); - --ease-in-quint: cubic-bezier(.755, .05, .855, .06); - --ease-in-expo: cubic-bezier(.95, .05, .795, .035); - --ease-in-circ: cubic-bezier(.6, .04, .98, .335); - - --ease-out-quad: cubic-bezier(.25, .46, .45, .94); - --ease-out-cubic: cubic-bezier(.215, .61, .355, 1); - --ease-out-quart: cubic-bezier(.165, .84, .44, 1); - --ease-out-quint: cubic-bezier(.23, 1, .32, 1); - --ease-out-expo: cubic-bezier(.19, 1, .22, 1); - --ease-out-circ: cubic-bezier(.075, .82, .165, 1); - - --ease-in-out-quad: cubic-bezier(.455, .03, .515, .955); - --ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1); - --ease-in-out-quart: cubic-bezier(.77, 0, .175, 1); - --ease-in-out-quint: cubic-bezier(.86, 0, .07, 1); - --ease-in-out-expo: cubic-bezier(1, 0, 0, 1); - --ease-in-out-circ: cubic-bezier(.785, .135, .15, .86); -} - -html { - scroll-behavior: smooth; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow-x: hidden; -} - -body { - font-family: var(--font-sans); - background: var(--bg); - color: var(--text); - line-height: 1.6; - overflow-x: hidden; - width: 100%; - max-width: 100vw; - background-image: radial-gradient(rgba(48, 80, 255, 0.04) 1px, transparent 1px); - background-size: 32px 32px; -} - -a { - color: var(--primary); - text-decoration: none; - transition: color 0.2s var(--ease-out-quad); -} -a:hover { - color: var(--primary-light); -} - -strong { - color: #fff; - font-weight: 600; -} - -/* --- Noise Overlay --- */ -#noise-overlay { - position: fixed; - inset: 0; - width: 100%; - height: 100%; - z-index: 50; - pointer-events: none; - mix-blend-mode: soft-light; -} - -/* --- Ambient Glow --- */ -.ambient-glow { - position: fixed; - pointer-events: none; - z-index: 0; - border-radius: 50%; - filter: blur(120px); - opacity: 0.15; -} -.glow-1 { - width: 600px; - height: 600px; - background: var(--primary); - top: -200px; - left: -200px; - opacity: 0.08; -} -.glow-2 { - width: 500px; - height: 500px; - background: var(--primary-dim); - bottom: 20%; - right: -150px; - opacity: 0.06; -} - -/* --- Container --- */ -.container { - max-width: var(--container); - margin: 0 auto; - padding: 0 24px; -} - -/* --- Navigation --- */ -.nav { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 100; - background: rgba(7, 7, 13, 0.8); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--border); - transition: border-bottom-color 0.3s var(--ease-out-quad); -} - -.nav-inner { - max-width: var(--container); - margin: 0 auto; - padding: 0 24px; - height: 60px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - color: var(--text); - font-weight: 600; - font-size: 15px; - transition: color 0.2s var(--ease-out-quad); -} -.nav-logo:hover { color: var(--primary-light); } - -.nav-nous-logo { - width: 22px; - height: 22px; - border-radius: 4px; -} - -.nav-by { - font-weight: 400; - color: var(--text-muted); - font-size: 13px; -} - -.nav-links { - display: flex; - align-items: center; - gap: 28px; -} - -.nav-links a { - color: var(--text-dim); - font-size: 14px; - font-weight: 500; - display: flex; - align-items: center; - gap: 4px; - transition: color 0.2s var(--ease-out-quad); -} -.nav-links a:hover { color: #fff; } - -.external-icon { opacity: 0.4; } - -/* --- Hamburger & Mobile Nav --- */ -.nav-hamburger { - display: none; - background: none; - border: none; - cursor: pointer; - padding: 6px; - width: 34px; - height: 34px; - flex-direction: column; - justify-content: center; - gap: 5px; -} - -.hamburger-bar { - display: block; - width: 20px; - height: 2px; - background: var(--text-dim); - border-radius: 1px; - transition: transform 0.25s var(--ease-out-quint), opacity 0.2s var(--ease-out-quad); - transform-origin: center; -} - -.nav-hamburger.open .hamburger-bar:nth-child(1) { - transform: translateY(7px) rotate(45deg); -} - -.nav-hamburger.open .hamburger-bar:nth-child(2) { - opacity: 0; -} - -.nav-hamburger.open .hamburger-bar:nth-child(3) { - transform: translateY(-7px) rotate(-45deg); -} - -.nav-mobile { - display: none; -} - -.nav-mobile.open { - display: flex; - flex-direction: column; - position: absolute; - top: 60px; - left: 0; - right: 0; - background: rgba(7, 7, 13, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--border); - padding: 16px 24px; - gap: 16px; -} - -.nav-mobile a { - color: var(--text-dim); - font-size: 15px; - font-weight: 500; - padding: 4px 0; - transition: color 0.2s var(--ease-out-quad); -} - -.nav-mobile a:hover { - color: #fff; -} - -/* --- Hero --- */ -.hero { - position: relative; - z-index: 1; - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 120px 24px 80px; - text-align: center; -} - -.hero-content { - max-width: 760px; -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 16px; - background: rgba(48, 80, 255, 0.08); - border: 1px solid rgba(48, 80, 255, 0.18); - border-radius: 100px; - font-size: 13px; - color: var(--text-dim); - margin-bottom: 32px; - font-weight: 450; -} - -.badge-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--primary); - display: inline-block; - animation: pulse-dot 2s var(--ease-in-out-quad) infinite; -} - -@keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.hero-ascii { - margin-bottom: 28px; - font-family: 'JetBrains Mono', monospace; - font-variant-ligatures: none; - font-size: clamp(4px, 0.95vw, 11px); - line-height: 1.15; - color: var(--primary-light); - text-align: center; - text-shadow: 0 0 20px rgba(48, 80, 255, 0.3); - opacity: 0.85; - transition: opacity 0.3s var(--ease-out-cubic); - overflow-x: auto; - white-space: pre; -} - -.hero-ascii:hover { - opacity: 1; -} - -.hero-title { - font-size: clamp(36px, 6vw, 56px); - font-weight: 700; - line-height: 1.15; - letter-spacing: -0.03em; - margin-bottom: 20px; - color: #fff; -} - -.hero-gradient { - background: linear-gradient(135deg, var(--primary), var(--primary-light), #90B0FF); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.hero-subtitle { - font-size: 17px; - line-height: 1.7; - color: var(--text-dim); - max-width: 620px; - margin: 0 auto 36px; -} - -.hero-install { - margin-bottom: 32px; -} - -/* --- Install Widget (hero tabbed installer) --- */ -.install-widget { - max-width: 740px; - margin: 0 auto; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: border-color 0.3s var(--ease-out-quad); -} - -.install-widget:hover { - border-color: var(--border-hover); -} - -.install-widget-header { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); -} - -.install-dots { - display: flex; - gap: 6px; - flex-shrink: 0; -} - -.install-dots .dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -.install-tabs { - display: flex; - gap: 4px; - flex-wrap: wrap; -} - -.install-tab { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 14px; - border: none; - border-radius: 6px; - font-family: var(--font-sans); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); - background: transparent; - color: var(--text-muted); -} - -.install-tab:hover { - color: var(--text-dim); - background: rgba(255, 255, 255, 0.04); -} - -.install-tab.active { - background: rgba(48, 80, 255, 0.14); - color: var(--primary-light); -} - -.install-tab svg { - flex-shrink: 0; -} - -.install-widget-body { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - font-family: var(--font-mono); - font-size: 13px; - color: var(--text); - overflow-x: auto; -} - -.install-prompt { - color: var(--primary-light); - font-weight: 600; - flex-shrink: 0; - opacity: 0.7; -} - -.install-widget-body code { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; - transition: opacity 0.15s var(--ease-out-quad); -} - -/* --- Code block tabs (install step section) --- */ -.code-tabs { - display: flex; - gap: 2px; -} - -.code-tab { - padding: 3px 10px; - border: none; - border-radius: 4px; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); - background: transparent; - color: var(--text-muted); -} - -.code-tab:hover { - color: var(--text-dim); - background: rgba(255, 255, 255, 0.04); -} - -.code-tab.active { - background: rgba(48, 80, 255, 0.12); - color: var(--primary-light); -} - -.copy-btn { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - font-family: var(--font-sans); - font-size: 12px; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); -} -.copy-btn:hover { - color: var(--primary-light); - background: rgba(48, 80, 255, 0.1); -} -.copy-btn:active { - transform: scale(0.95); -} - -.install-note { - font-size: 13px; - color: var(--text-muted); - margin-top: 12px; -} - -.hero-links { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; -} - -.btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 11px 24px; - border-radius: var(--radius); - font-size: 14px; - font-weight: 550; - transition: background 0.25s var(--ease-out-quint), border-color 0.25s var(--ease-out-quad), color 0.2s var(--ease-out-quad), transform 0.25s var(--ease-out-quint); - border: 1px solid transparent; - will-change: transform; -} - -.btn-primary { - background: rgba(48, 80, 255, 0.12); - color: var(--primary-light); - border-color: rgba(48, 80, 255, 0.25); -} -.btn-primary:hover { - background: rgba(48, 80, 255, 0.22); - border-color: rgba(48, 80, 255, 0.4); - color: #fff; -} - -@media (hover: hover) and (pointer: fine) { - .btn-primary:hover { - transform: translateY(-1px); - } -} -.btn:active { - transform: scale(0.97); -} - -/* --- Sections --- */ -.section { - position: relative; - z-index: 1; - padding: 80px 0; -} - -.section-header { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - margin-bottom: 48px; -} - -.section-header h2 { - font-size: 28px; - font-weight: 650; - color: #fff; - letter-spacing: -0.02em; -} - -.section-desc { - color: var(--text-dim); - font-size: 16px; - line-height: 1.7; - max-width: 640px; - margin: 0 auto 40px; - text-align: center; -} - -/* --- Features Grid --- */ -.features-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; -} - -.feature-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - transition: border-color 0.3s var(--ease-out-quad), background 0.3s var(--ease-out-quad), transform 0.3s var(--ease-out-quint); - will-change: transform; -} - -.feature-card:hover { - border-color: var(--border-hover); - background: var(--bg-card-hover); -} - -@media (hover: hover) and (pointer: fine) { - .feature-card:hover { - transform: translateY(-2px); - } -} - -.feature-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} - -.feature-icon { - color: var(--primary-light); - opacity: 0.85; - flex-shrink: 0; - display: flex; - line-height: 0; -} - -.feature-card h3 { - font-size: 15px; - font-weight: 600; - color: #fff; - letter-spacing: -0.01em; -} - -.feature-card p { - font-size: 14px; - color: var(--text-dim); - line-height: 1.65; -} - -/* --- Terminal Demo --- */ -.section-demo { - padding-bottom: 60px; - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); -} - -.terminal-window { - background: #0c0c14; - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - max-width: 800px; - margin: 0 auto; -} - -.terminal-header { - display: flex; - align-items: center; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); - gap: 12px; -} - -.terminal-dots { - display: flex; - gap: 6px; -} - -.dot { - width: 10px; - height: 10px; - border-radius: 50%; -} -.dot-red { background: #ff5f57; } -.dot-yellow { background: #febc2e; } -.dot-green { background: #28c840; } - -.terminal-title { - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-muted); -} - -.terminal-body { - padding: 20px 24px; - height: 340px; - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.7; - white-space: pre-wrap; - overflow-y: auto; - overflow-x: hidden; -} - -.terminal-cursor { - animation: blink 1s step-end infinite; - color: var(--primary-light); - opacity: 0.8; -} - -@keyframes blink { - 0%, 100% { opacity: 0.8; } - 50% { opacity: 0; } -} - -/* Terminal demo colors */ -.t-prompt { color: var(--primary-light); } -.t-cmd { color: #fff; } -.t-dim { color: var(--text-muted); } -.t-text { color: var(--text-dim); } -.t-green { color: #4ade80; } -.t-blue { color: #60a5fa; } -.t-accent { color: var(--primary-light); } -.t-highlight { color: #90B0FF; } -.t-tool { color: var(--text-muted); } - -/* --- Specs Toggle --- */ -.features-more { - text-align: center; - margin-top: 32px; -} - -.more-toggle { - background: none; - border: 1px solid var(--border); - color: var(--text-dim); - font-size: 14px; - font-family: inherit; - padding: 8px 20px; - border-radius: 6px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - transition: color 0.2s var(--ease-out-quad), border-color 0.2s var(--ease-out-quad); -} - -.more-toggle:hover { - color: var(--primary-light); - border-color: var(--primary-light); -} -.more-toggle:active { - transform: scale(0.97); -} - -.more-chevron { - transition: transform 0.3s var(--ease-in-out-cubic); -} - -.more-toggle.open .more-chevron { - transform: rotate(180deg); -} - -.specs-wrapper { - max-height: 0; - overflow: hidden; - transition: max-height 0.4s var(--ease-out-quart), opacity 0.3s var(--ease-out-quad); - opacity: 0; -} - -.specs-wrapper.open { - opacity: 1; -} - -/* --- Specs --- */ -.section-specs { -} - -.specs-list { - max-width: 720px; - margin: 0 auto; - padding-top: 24px; -} - -.spec-row { - display: grid; - grid-template-columns: 120px 1fr; - gap: 24px; - padding: 24px 0; - border-bottom: 1px solid var(--border); -} - -.spec-row:last-child { - border-bottom: none; -} - -.spec-label { - font-size: 14px; - font-weight: 600; - color: var(--primary-light); - padding-top: 2px; -} - -.spec-value { - font-size: 15px; - color: var(--text-dim); - line-height: 1.7; -} - -.spec-value a { - color: var(--text); - border-bottom: 1px solid var(--border-hover); - transition: border-color 0.2s var(--ease-out-quad), color 0.2s var(--ease-out-quad); -} - -.spec-value a:hover { - color: var(--primary-light); - border-color: var(--primary-light); -} - -/* --- Install Section --- */ -.section-install { - border-top: 1px solid var(--border); -} - -.install-steps { - display: grid; - gap: 28px; - max-width: 640px; - margin: 0 auto; -} - -.install-step { - display: flex; - gap: 20px; -} - -.step-number { - flex-shrink: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(48, 80, 255, 0.1); - border: 1px solid rgba(48, 80, 255, 0.2); - border-radius: 50%; - font-size: 14px; - font-weight: 600; - color: var(--primary-light); - margin-top: 2px; -} - -.step-content { - flex: 1; - min-width: 0; -} - -.step-content h4 { - font-size: 16px; - font-weight: 600; - color: #fff; - margin-bottom: 10px; -} - -.step-optional { - font-size: 12px; - font-weight: 400; - color: var(--text-muted); -} - -.step-note { - font-size: 13px; - color: var(--text-muted); - margin-top: 8px; -} - -.code-block { - background: #0c0c14; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; -} - -.code-block-sm { - max-width: 640px; -} - -.code-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 14px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-muted); -} - -.code-block pre { - padding: 14px 16px; - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.6; - color: var(--text); - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; -} - -.code-comment { - color: var(--text-muted); -} - -.install-windows { - margin-top: 48px; - padding-top: 32px; - border-top: 1px solid var(--border); - max-width: 640px; - margin-left: auto; - margin-right: auto; -} - -.install-windows p { - font-size: 14px; - color: var(--text-dim); - margin-bottom: 12px; -} - -/* --- Footer --- */ -.footer { - position: relative; - z-index: 1; - padding: 40px 0 32px; - border-top: 1px solid var(--border); -} - -.footer-copy { - text-align: center; - font-size: 13px; - color: var(--text-muted); -} - -.footer-copy a { - color: var(--text-dim); - transition: color 0.2s var(--ease-out-quad); -} - -.footer-copy a:hover { - color: var(--primary-light); -} - -/* --- Scroll Animations --- */ -.fade-in { - opacity: 0; - transform: translateY(20px); - transition: opacity 0.6s var(--ease-out-quart), transform 0.6s var(--ease-out-quart); - will-change: transform, opacity; -} - -.fade-in.visible { - opacity: 1; - transform: translateY(0); -} - -/* --- Responsive --- */ - -/* Clamp ambient glows so they can't cause horizontal scroll */ -@media (max-width: 900px) { - .ambient-glow { display: none; } - - .features-grid { - grid-template-columns: repeat(2, 1fr); - } - -} - -@media (max-width: 640px) { - /* --- Global mobile --- */ - .container { - padding: 0 16px; - } - - .section { - padding: 50px 0; - } - - .section-header { - margin-bottom: 32px; - } - - .section-header h2 { - font-size: 20px; - } - - .section-desc { - font-size: 14px; - } - - /* --- Nav --- */ - .nav-inner { - padding: 0 16px; - } - - .nav-links { - display: none; - } - - .nav-hamburger { - display: flex; - } - - /* --- Hero --- */ - .hero { - padding: 90px 16px 50px; - min-height: auto; - } - - .hero-content { - max-width: 100%; - } - - .hero-badge { - font-size: 11px; - padding: 5px 12px; - margin-bottom: 24px; - } - - .hero-ascii { - font-size: 3.5px; - } - - .hero-title { - font-size: 26px; - margin-bottom: 14px; - } - - .hero-subtitle { - font-size: 14px; - line-height: 1.6; - margin: 0 auto 28px; - } - - .install-widget-body { - font-size: 10px; - padding: 10px 12px; - } - - .install-widget-body code { - overflow: hidden; - text-overflow: ellipsis; - display: block; - } - - .install-widget-header { - padding: 8px 12px; - gap: 10px; - } - - .install-tabs { - gap: 2px; - } - - .install-tab { - padding: 4px 10px; - font-size: 11px; - } - - .install-tab svg { - display: none; - } - - .copy-btn { - padding: 3px 6px; - } - - .copy-btn .copy-text { display: none; } - - .install-note { - font-size: 11px; - } - - .hero-links { - flex-direction: column; - align-items: stretch; - } - - .hero-links .btn { - justify-content: center; - } - - /* --- Grids → single column --- */ - .features-grid { - grid-template-columns: 1fr; - } - - .spec-row { - grid-template-columns: 1fr; - gap: 6px; - padding: 18px 0; - } - - .feature-card { - padding: 16px 18px; - } - - .feature-card p { - font-size: 13px; - line-height: 1.5; - } - - /* --- Terminal demo --- */ - .terminal-body { - font-size: 11px; - padding: 14px; - height: 260px; - } - - /* --- Install steps --- */ - .install-steps { - max-width: 100%; - } - - .install-step { - gap: 14px; - } - - .step-number { - width: 28px; - height: 28px; - font-size: 13px; - } - - .code-block pre { - font-size: 11px; - word-break: break-all; - } - - .install-windows { - max-width: 100%; - } - - /* --- Footer --- */ - .footer { - padding: 32px 0 24px; - } - -} - -/* --- Reduced Motion --- */ -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } - - .fade-in { - opacity: 1; - transform: none; - } - - .hero-ascii { - opacity: 0.85; - } -} - -/* --- Selection --- */ -::selection { - background: rgba(48, 80, 255, 0.25); - color: #fff; -} - -/* --- Scrollbar --- */ -::-webkit-scrollbar { - width: 6px; - height: 6px; -} -::-webkit-scrollbar-track { - background: var(--bg); -} -::-webkit-scrollbar-thumb { - background: var(--border-hover); - border-radius: 3px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--primary-dim); -} From 51d5c7648852cdc2674d3b00b43ee0300cca62c3 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 15 Apr 2026 20:12:52 -0700 Subject: [PATCH 269/849] feat: add vercel deployment, remove old landing page (#10686) --- .github/workflows/deploy-site.yml | 25 +- landingpage/apple-touch-icon.png | Bin 28150 -> 0 bytes landingpage/favicon-16x16.png | Bin 870 -> 0 bytes landingpage/favicon-32x32.png | Bin 2511 -> 0 bytes landingpage/favicon.ico | Bin 8139 -> 0 bytes landingpage/hermes-agent-banner.png | Bin 12333 -> 0 bytes landingpage/icon-192.png | Bin 29805 -> 0 bytes landingpage/icon-512.png | Bin 137587 -> 0 bytes landingpage/index.html | 665 --------------- landingpage/nous-logo.png | Bin 20988 -> 0 bytes landingpage/script.js | 521 ------------ landingpage/style.css | 1178 --------------------------- 12 files changed, 11 insertions(+), 2378 deletions(-) delete mode 100644 landingpage/apple-touch-icon.png delete mode 100644 landingpage/favicon-16x16.png delete mode 100644 landingpage/favicon-32x32.png delete mode 100644 landingpage/favicon.ico delete mode 100644 landingpage/hermes-agent-banner.png delete mode 100644 landingpage/icon-192.png delete mode 100644 landingpage/icon-512.png delete mode 100644 landingpage/index.html delete mode 100644 landingpage/nous-logo.png delete mode 100644 landingpage/script.js delete mode 100644 landingpage/style.css diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 480b236f8..44da745b9 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -1,11 +1,12 @@ name: Deploy Site on: + release: + types: [published] push: branches: [main] paths: - 'website/**' - - 'landingpage/**' - 'skills/**' - 'optional-skills/**' - '.github/workflows/deploy-site.yml' @@ -20,8 +21,14 @@ concurrency: cancel-in-progress: false jobs: - build-and-deploy: - # Only run on the upstream repository, not on forks + deploy-vercel: + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - name: Trigger Vercel Deploy + run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" + + deploy-docs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest environment: @@ -62,20 +69,10 @@ jobs: run: npm run build working-directory: website - - name: Stage deployment - run: | - mkdir -p _site/docs - # Landing page at root - cp -r landingpage/* _site/ - # Docusaurus at /docs/ - cp -r website/build/* _site/docs/ - # CNAME so GitHub Pages keeps the custom domain between deploys - echo "hermes-agent.nousresearch.com" > _site/CNAME - - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: - path: _site + path: website/build - name: Deploy to GitHub Pages id: deploy diff --git a/landingpage/apple-touch-icon.png b/landingpage/apple-touch-icon.png deleted file mode 100644 index c5da175f8eb397b579c00678b7687bfd930cdc78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28150 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 Oy6?^MDf-_L(Ek87?}i%y diff --git a/landingpage/hermes-agent-banner.png b/landingpage/hermes-agent-banner.png deleted file mode 100644 index 2c4a160ceb721402e21ae107cbea45bbd80702b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12333 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�!X5p5sf&LloNSTYkUfT-^- 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

ML+Q_oprI)7p(3Ki8 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

sJu{t`y8DsrS@F4j_h= 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 diff --git a/landingpage/index.html b/landingpage/index.html deleted file mode 100644 index e24ed11c4..000000000 --- a/landingpage/index.html +++ /dev/null @@ -1,665 +0,0 @@ - - - - - - Hermes Agent — An Agent That Grows With You - - - - - - - - - - - - - - - - - - - - - - - -

-
- - - -
-
-
- - Open Source • MIT License -
- - - - -

- An agent that
- grows with you. -

- -

- It's not a coding copilot tethered to an IDE or a chatbot wrapper - around a single API. It's an autonomous agent that - lives on your server, remembers what it learns, and gets more capable - the longer it runs. -

- -
-
-
-
- - - -
-
- -
-
-
- $ - curl -fsSL - https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh - | bash - -
-
-

- Works on Linux, macOS & WSL2 · No prerequisites · Installs - everything automatically -

-
- - -
-
- -
-
-
-

Get started in 60 seconds

-
- -
-
-
1
-
-

Install

-
-
-
- -
- -
-
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
-
-

- Installs uv, Python 3.11, clones the repo, sets up everything. - No sudo needed. -

-
-
- -
-
2
-
-

Configure

-
-
- bash - -
-
# Interactive setup wizard
-hermes setup
-
-# Or choose your model
-hermes model
-
-

- Connect to Nous Portal (OAuth), OpenRouter (API key), or your - own endpoint. -

-
-
- -
-
3
-
-

Start chatting

-
-
- bash - -
-
hermes
-
-

- That's it. Full interactive CLI with tools, memory, and skills. -

-
-
- -
-
4
-
-

- Go multi-platform (optional) -

-
-
- bash - -
-
# Interactive gateway setup wizard
-hermes gateway setup
-
-# Start the messaging gateway
-hermes gateway
-
-# Install as a system service
-hermes gateway install
-
-

- Walk through connecting Telegram, Discord, Slack, or WhatsApp. - Runs as a systemd service. -

-
-
- -
-
5
-
-

Keep it up to date

-
-
- bash - -
-
hermes update
-
-

- Pulls the latest changes and reinstalls dependencies. Run - anytime to get new features and fixes. -

-
-
-
- -
-

- Native Windows support is extremely experimental and unsupported. - Please install - WSL2 - and run Hermes Agent from there. -

-
-
-
- - -
-
-
-

See it in action

-
- -
-
-
- - - -
- hermes -
-
-
-
-
- - -
-
-
-

Features

-
- -
-
-
-
- - - -
-

Lives Where You Do

-
-

- Telegram, Discord, Slack, WhatsApp, and CLI from a single gateway - — start on one, pick up on another. -

-
- -
-
-
- - - - -
-

Grows the Longer It Runs

-
-

- Persistent memory and auto-generated skills — it learns your - projects and never forgets how it solved a problem. -

-
- -
-
-
- - - - -
-

Scheduled Automations

-
-

- Natural language cron scheduling for reports, backups, and - briefings — running unattended through the gateway. -

-
- -
-
-
- - - - - - -
-

Delegates & Parallelizes

-
-

- Isolated subagents with their own conversations, terminals, and - Python RPC scripts for zero-context-cost pipelines. -

-
- -
-
-
- - - - -
-

Real Sandboxing

-
-

- Five backends — local, Docker, SSH, Singularity, Modal — with - container hardening and namespace isolation. -

-
- -
-
-
- - - - - -
-

Full Web & Browser Control

-
-

- Web search, browser automation, vision, image generation, - text-to-speech, and multi-model reasoning. -

-
-
- -
- -
- -
-
-
-

Tools

-

- 40+ built-in — web search, terminal, file system, browser - automation, vision, image generation, text-to-speech, code - execution, subagent delegation, memory, task planning, cron - scheduling, multi-model reasoning, and more. -

-
- -
-

Platforms

-

- Telegram, Discord, Slack, WhatsApp, Signal, Email, and CLI — all - from a single gateway. Connect to - Nous Portal, OpenRouter, or any OpenAI-compatible API. -

-
- -
-

Environments

-

- Run locally, in Docker, over SSH, on Modal, Daytona, or - Singularity. Container hardening with read-only root, dropped - capabilities, and namespace isolation. -

-
- -
-

Skills

-

- 40+ bundled skills covering MLOps, GitHub workflows, research, - and more. The agent creates new skills on the fly and shares - them via the open - agentskills.io - format. Install community skills from - ClawHub, - LobeHub, and GitHub. -

-
- -
-

Research

-

- Batch trajectory generation with parallel workers and - checkpointing. Atropos integration for RL training. Export to - ShareGPT for fine-tuning with trajectory compression. -

-
-
-
-
-
- - - - - - diff --git a/landingpage/nous-logo.png b/landingpage/nous-logo.png deleted file mode 100644 index cfea9a661337855b90209ab3160d8e07a16e183b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20988 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 diff --git a/landingpage/script.js b/landingpage/script.js deleted file mode 100644 index 4cd097bdb..000000000 --- a/landingpage/script.js +++ /dev/null @@ -1,521 +0,0 @@ -// ========================================================================= -// Hermes Agent Landing Page — Interactions -// ========================================================================= - -// --- Platform install commands --- -const PLATFORMS = { - linux: { - command: - "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", - prompt: "$", - note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically", - stepNote: - "Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.", - }, -}; - -function detectPlatform() { - return "linux"; -} - -function switchPlatform(platform) { - const cfg = PLATFORMS[platform]; - if (!cfg) return; - - // Update hero install widget - const commandEl = document.getElementById("install-command"); - const promptEl = document.getElementById("install-prompt"); - const noteEl = document.getElementById("install-note"); - - if (commandEl) commandEl.textContent = cfg.command; - if (promptEl) promptEl.textContent = cfg.prompt; - if (noteEl) noteEl.textContent = cfg.note; - - // Update active tab in hero - document.querySelectorAll(".install-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.platform === platform); - }); - - // Sync the step section tabs too - switchStepPlatform(platform); -} - -function switchStepPlatform(platform) { - const cfg = PLATFORMS[platform]; - if (!cfg) return; - - const commandEl = document.getElementById("step1-command"); - const copyBtn = document.getElementById("step1-copy"); - const noteEl = document.getElementById("step1-note"); - - if (commandEl) commandEl.textContent = cfg.command; - if (copyBtn) copyBtn.setAttribute("data-text", cfg.command); - if (noteEl) noteEl.textContent = cfg.stepNote; - - // Update active tab in step section - document.querySelectorAll(".code-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.platform === platform); - }); -} - -function toggleMobileNav() { - document.getElementById("nav-mobile").classList.toggle("open"); - document.getElementById("nav-hamburger").classList.toggle("open"); -} - -function toggleSpecs() { - const wrapper = document.getElementById("specs-wrapper"); - const btn = document.getElementById("specs-toggle"); - const label = btn.querySelector(".toggle-label"); - const isOpen = wrapper.classList.contains("open"); - - if (isOpen) { - wrapper.style.maxHeight = wrapper.scrollHeight + "px"; - requestAnimationFrame(() => { - wrapper.style.maxHeight = "0"; - }); - wrapper.classList.remove("open"); - btn.classList.remove("open"); - if (label) label.textContent = "More details"; - } else { - wrapper.classList.add("open"); - wrapper.style.maxHeight = wrapper.scrollHeight + "px"; - btn.classList.add("open"); - if (label) label.textContent = "Less"; - wrapper.addEventListener( - "transitionend", - () => { - if (wrapper.classList.contains("open")) { - wrapper.style.maxHeight = "none"; - } - }, - { once: true } - ); - } -} - -// --- Copy to clipboard --- -function copyInstall() { - const text = document.getElementById("install-command").textContent; - navigator.clipboard.writeText(text).then(() => { - const btn = document.querySelector(".install-widget-body .copy-btn"); - const original = btn.querySelector(".copy-text").textContent; - btn.querySelector(".copy-text").textContent = "Copied!"; - btn.style.color = "var(--primary-light)"; - setTimeout(() => { - btn.querySelector(".copy-text").textContent = original; - btn.style.color = ""; - }, 2000); - }); -} - -function copyText(btn) { - const text = btn.getAttribute("data-text"); - navigator.clipboard.writeText(text).then(() => { - const original = btn.textContent; - btn.textContent = "Copied!"; - btn.style.color = "var(--primary-light)"; - setTimeout(() => { - btn.textContent = original; - btn.style.color = ""; - }, 2000); - }); -} - -// --- Scroll-triggered fade-in --- -function initScrollAnimations() { - const elements = document.querySelectorAll( - ".feature-card, .install-step, " + - ".section-header, .terminal-window", - ); - - elements.forEach((el) => el.classList.add("fade-in")); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - // Stagger children within grids - const parent = entry.target.parentElement; - if (parent) { - const siblings = parent.querySelectorAll(".fade-in"); - let idx = Array.from(siblings).indexOf(entry.target); - if (idx < 0) idx = 0; - setTimeout(() => { - entry.target.classList.add("visible"); - }, idx * 60); - } else { - entry.target.classList.add("visible"); - } - observer.unobserve(entry.target); - } - }); - }, - { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }, - ); - - elements.forEach((el) => observer.observe(el)); -} - -// --- Terminal Demo --- -const CURSOR = ''; - -const demoSequence = [ - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "Research the latest approaches to GRPO training and write a summary", - delay: 30, - }, - { type: "pause", ms: 600 }, - { - type: "output", - lines: [ - "", - ' web_search "GRPO reinforcement learning 2026" 1.2s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_extract arxiv.org/abs/2402.03300 3.1s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_search "GRPO vs PPO ablation results" 0.9s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' web_extract huggingface.co/blog/grpo 2.8s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' write_file ~/research/grpo-summary.md 0.1s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Done! I\'ve written a summary covering:', - "", - ' GRPO\'s group-relative advantage (no critic model needed)', - ' Comparison with PPO/DPO on reasoning benchmarks', - ' Implementation notes for Axolotl and TRL', - "", - 'Saved to ~/research/grpo-summary.md', - ], - }, - { type: "pause", ms: 2500 }, - - { type: "clear" }, - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues", - delay: 30, - }, - { type: "pause", ms: 600 }, - { - type: "output", - lines: [ - "", - ' delegate_task "review PR #42 changes" 2.1s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - ' git diff main..pr-42 0.4s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' patch tools/registry.py 0.1s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' python -m pytest tests/ -x 3.2s', - ], - }, - { type: "pause", ms: 400 }, - { - type: "output", - lines: [ - ' git commit -m "fix: handle empty tool schemas" 0.3s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Found 2 issues in the PR and fixed both:', - "", - ' Empty tool schema crash in registry.py — added guard', - ' Missing error handling in delegate_tool.py — added try/except', - "", - 'Tests pass. Committed the fix and pushed to the PR branch.', - 'I also saved a skill for this PR review pattern.', - ], - }, - { type: "pause", ms: 2500 }, - - { type: "clear" }, - { type: "prompt", text: "❯ " }, - { - type: "type", - text: "How did we fix that Docker networking issue?", - delay: 35, - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - ' session_search "Docker networking" 1.4s', - ], - }, - { type: "pause", ms: 500 }, - { - type: "output", - lines: [ - "", - 'Found it — from a session on February 12th:', - "", - 'The containers couldn\'t reach each other because the compose', - 'file was using the default bridge network. We switched to a', - 'custom network with driver: overlay, added explicit', - 'aliases, and set dns: 8.8.8.8 as a fallback.', - "", - 'The fix was committed in docker-compose.prod.yml.', - ], - }, - { type: "pause", ms: 3000 }, -]; - -class TerminalDemo { - constructor(container) { - this.container = container; - this.running = false; - this.content = ""; - } - - async start() { - if (this.running) return; - this.running = true; - - while (this.running) { - for (const step of demoSequence) { - if (!this.running) return; - await this.execute(step); - } - this.clear(); - await this.sleep(1000); - } - } - - stop() { - this.running = false; - } - - async execute(step) { - switch (step.type) { - case "prompt": - this.append(`${step.text}`); - break; - case "type": - for (const char of step.text) { - if (!this.running) return; - this.append(`${char}`); - await this.sleep(step.delay || 30); - } - break; - case "output": - for (const line of step.lines) { - if (!this.running) return; - this.append("\n" + line); - await this.sleep(50); - } - break; - case "pause": - await this.sleep(step.ms); - break; - case "clear": - this.clear(); - break; - } - } - - append(html) { - this.content += html; - this.render(); - } - - render() { - this.container.innerHTML = this.content + CURSOR; - this.container.scrollTop = this.container.scrollHeight; - } - - clear() { - this.content = ""; - this.container.innerHTML = ""; - } - - sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} - -// --- Noise Overlay (ported from hermes-chat NoiseOverlay) --- -function initNoiseOverlay() { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - if (typeof THREE === "undefined") return; - - const canvas = document.getElementById("noise-overlay"); - if (!canvas) return; - - const vertexShader = ` - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `; - - const fragmentShader = ` - uniform vec2 uRes; - uniform float uDpr, uSize, uDensity, uOpacity; - uniform vec3 uColor; - varying vec2 vUv; - - float hash(vec2 p) { - vec3 p3 = fract(vec3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); - } - - void main() { - float n = hash(floor(vUv * uRes / (uSize * uDpr))); - gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity; - } - `; - - function hexToVec3(hex) { - const c = hex.replace("#", ""); - return new THREE.Vector3( - parseInt(c.substring(0, 2), 16) / 255, - parseInt(c.substring(2, 4), 16) / 255, - parseInt(c.substring(4, 6), 16) / 255, - ); - } - - const renderer = new THREE.WebGLRenderer({ - alpha: true, - canvas, - premultipliedAlpha: false, - }); - renderer.setClearColor(0x000000, 0); - - const scene = new THREE.Scene(); - const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - const geo = new THREE.PlaneGeometry(2, 2); - - const mat = new THREE.ShaderMaterial({ - vertexShader, - fragmentShader, - transparent: true, - uniforms: { - uColor: { value: hexToVec3("#8090BB") }, - uDensity: { value: 0.1 }, - uDpr: { value: 1 }, - uOpacity: { value: 0.4 }, - uRes: { value: new THREE.Vector2() }, - uSize: { value: 1.0 }, - }, - }); - - scene.add(new THREE.Mesh(geo, mat)); - - function resize() { - const dpr = window.devicePixelRatio; - const w = window.innerWidth; - const h = window.innerHeight; - renderer.setSize(w, h); - renderer.setPixelRatio(dpr); - mat.uniforms.uRes.value.set(w * dpr, h * dpr); - mat.uniforms.uDpr.value = dpr; - } - - resize(); - window.addEventListener("resize", resize); - - function loop() { - requestAnimationFrame(loop); - renderer.render(scene, camera); - } - loop(); -} - -// --- Initialize --- -document.addEventListener("DOMContentLoaded", () => { - const detectedPlatform = detectPlatform(); - switchPlatform(detectedPlatform); - - initScrollAnimations(); - initNoiseOverlay(); - - const terminalEl = document.getElementById("terminal-demo"); - - if (terminalEl) { - const demo = new TerminalDemo(terminalEl); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - demo.start(); - } else { - demo.stop(); - } - }); - }, - { threshold: 0.3 }, - ); - - observer.observe(document.querySelector(".terminal-window")); - } - - const nav = document.querySelector(".nav"); - let ticking = false; - window.addEventListener("scroll", () => { - if (!ticking) { - requestAnimationFrame(() => { - if (window.scrollY > 50) { - nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)"; - } else { - nav.style.borderBottomColor = ""; - } - ticking = false; - }); - ticking = true; - } - }); -}); diff --git a/landingpage/style.css b/landingpage/style.css deleted file mode 100644 index 30334df0d..000000000 --- a/landingpage/style.css +++ /dev/null @@ -1,1178 +0,0 @@ -/* ========================================================================= - Hermes Agent Landing Page - Colors: Nous Blue (#3050FF) palette - ========================================================================= */ - -/* --- Reset & Base --- */ -*, *::before, *::after { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --primary: #3050FF; - --primary-light: #5070FF; - --primary-dim: #2040CC; - --primary-dark: #1E30AA; - --bg: #0A0E1A; - --bg-card: #12182A; - --bg-card-hover: #1A2240; - --border: rgba(48, 80, 255, 0.1); - --border-hover: rgba(48, 80, 255, 0.22); - --text: #E8ECFF; - --text-dim: #8090BB; - --text-muted: #506090; - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - --container: 1080px; - --radius: 12px; - --radius-sm: 8px; - - --ease-in-quad: cubic-bezier(.55, .085, .68, .53); - --ease-in-cubic: cubic-bezier(.550, .055, .675, .19); - --ease-in-quart: cubic-bezier(.895, .03, .685, .22); - --ease-in-quint: cubic-bezier(.755, .05, .855, .06); - --ease-in-expo: cubic-bezier(.95, .05, .795, .035); - --ease-in-circ: cubic-bezier(.6, .04, .98, .335); - - --ease-out-quad: cubic-bezier(.25, .46, .45, .94); - --ease-out-cubic: cubic-bezier(.215, .61, .355, 1); - --ease-out-quart: cubic-bezier(.165, .84, .44, 1); - --ease-out-quint: cubic-bezier(.23, 1, .32, 1); - --ease-out-expo: cubic-bezier(.19, 1, .22, 1); - --ease-out-circ: cubic-bezier(.075, .82, .165, 1); - - --ease-in-out-quad: cubic-bezier(.455, .03, .515, .955); - --ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1); - --ease-in-out-quart: cubic-bezier(.77, 0, .175, 1); - --ease-in-out-quint: cubic-bezier(.86, 0, .07, 1); - --ease-in-out-expo: cubic-bezier(1, 0, 0, 1); - --ease-in-out-circ: cubic-bezier(.785, .135, .15, .86); -} - -html { - scroll-behavior: smooth; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow-x: hidden; -} - -body { - font-family: var(--font-sans); - background: var(--bg); - color: var(--text); - line-height: 1.6; - overflow-x: hidden; - width: 100%; - max-width: 100vw; - background-image: radial-gradient(rgba(48, 80, 255, 0.04) 1px, transparent 1px); - background-size: 32px 32px; -} - -a { - color: var(--primary); - text-decoration: none; - transition: color 0.2s var(--ease-out-quad); -} -a:hover { - color: var(--primary-light); -} - -strong { - color: #fff; - font-weight: 600; -} - -/* --- Noise Overlay --- */ -#noise-overlay { - position: fixed; - inset: 0; - width: 100%; - height: 100%; - z-index: 50; - pointer-events: none; - mix-blend-mode: soft-light; -} - -/* --- Ambient Glow --- */ -.ambient-glow { - position: fixed; - pointer-events: none; - z-index: 0; - border-radius: 50%; - filter: blur(120px); - opacity: 0.15; -} -.glow-1 { - width: 600px; - height: 600px; - background: var(--primary); - top: -200px; - left: -200px; - opacity: 0.08; -} -.glow-2 { - width: 500px; - height: 500px; - background: var(--primary-dim); - bottom: 20%; - right: -150px; - opacity: 0.06; -} - -/* --- Container --- */ -.container { - max-width: var(--container); - margin: 0 auto; - padding: 0 24px; -} - -/* --- Navigation --- */ -.nav { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 100; - background: rgba(7, 7, 13, 0.8); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--border); - transition: border-bottom-color 0.3s var(--ease-out-quad); -} - -.nav-inner { - max-width: var(--container); - margin: 0 auto; - padding: 0 24px; - height: 60px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.nav-logo { - display: flex; - align-items: center; - gap: 10px; - color: var(--text); - font-weight: 600; - font-size: 15px; - transition: color 0.2s var(--ease-out-quad); -} -.nav-logo:hover { color: var(--primary-light); } - -.nav-nous-logo { - width: 22px; - height: 22px; - border-radius: 4px; -} - -.nav-by { - font-weight: 400; - color: var(--text-muted); - font-size: 13px; -} - -.nav-links { - display: flex; - align-items: center; - gap: 28px; -} - -.nav-links a { - color: var(--text-dim); - font-size: 14px; - font-weight: 500; - display: flex; - align-items: center; - gap: 4px; - transition: color 0.2s var(--ease-out-quad); -} -.nav-links a:hover { color: #fff; } - -.external-icon { opacity: 0.4; } - -/* --- Hamburger & Mobile Nav --- */ -.nav-hamburger { - display: none; - background: none; - border: none; - cursor: pointer; - padding: 6px; - width: 34px; - height: 34px; - flex-direction: column; - justify-content: center; - gap: 5px; -} - -.hamburger-bar { - display: block; - width: 20px; - height: 2px; - background: var(--text-dim); - border-radius: 1px; - transition: transform 0.25s var(--ease-out-quint), opacity 0.2s var(--ease-out-quad); - transform-origin: center; -} - -.nav-hamburger.open .hamburger-bar:nth-child(1) { - transform: translateY(7px) rotate(45deg); -} - -.nav-hamburger.open .hamburger-bar:nth-child(2) { - opacity: 0; -} - -.nav-hamburger.open .hamburger-bar:nth-child(3) { - transform: translateY(-7px) rotate(-45deg); -} - -.nav-mobile { - display: none; -} - -.nav-mobile.open { - display: flex; - flex-direction: column; - position: absolute; - top: 60px; - left: 0; - right: 0; - background: rgba(7, 7, 13, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--border); - padding: 16px 24px; - gap: 16px; -} - -.nav-mobile a { - color: var(--text-dim); - font-size: 15px; - font-weight: 500; - padding: 4px 0; - transition: color 0.2s var(--ease-out-quad); -} - -.nav-mobile a:hover { - color: #fff; -} - -/* --- Hero --- */ -.hero { - position: relative; - z-index: 1; - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 120px 24px 80px; - text-align: center; -} - -.hero-content { - max-width: 760px; -} - -.hero-badge { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 16px; - background: rgba(48, 80, 255, 0.08); - border: 1px solid rgba(48, 80, 255, 0.18); - border-radius: 100px; - font-size: 13px; - color: var(--text-dim); - margin-bottom: 32px; - font-weight: 450; -} - -.badge-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--primary); - display: inline-block; - animation: pulse-dot 2s var(--ease-in-out-quad) infinite; -} - -@keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.hero-ascii { - margin-bottom: 28px; - font-family: 'JetBrains Mono', monospace; - font-variant-ligatures: none; - font-size: clamp(4px, 0.95vw, 11px); - line-height: 1.15; - color: var(--primary-light); - text-align: center; - text-shadow: 0 0 20px rgba(48, 80, 255, 0.3); - opacity: 0.85; - transition: opacity 0.3s var(--ease-out-cubic); - overflow-x: auto; - white-space: pre; -} - -.hero-ascii:hover { - opacity: 1; -} - -.hero-title { - font-size: clamp(36px, 6vw, 56px); - font-weight: 700; - line-height: 1.15; - letter-spacing: -0.03em; - margin-bottom: 20px; - color: #fff; -} - -.hero-gradient { - background: linear-gradient(135deg, var(--primary), var(--primary-light), #90B0FF); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.hero-subtitle { - font-size: 17px; - line-height: 1.7; - color: var(--text-dim); - max-width: 620px; - margin: 0 auto 36px; -} - -.hero-install { - margin-bottom: 32px; -} - -/* --- Install Widget (hero tabbed installer) --- */ -.install-widget { - max-width: 740px; - margin: 0 auto; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: border-color 0.3s var(--ease-out-quad); -} - -.install-widget:hover { - border-color: var(--border-hover); -} - -.install-widget-header { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); -} - -.install-dots { - display: flex; - gap: 6px; - flex-shrink: 0; -} - -.install-dots .dot { - width: 10px; - height: 10px; - border-radius: 50%; -} - -.install-tabs { - display: flex; - gap: 4px; - flex-wrap: wrap; -} - -.install-tab { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 14px; - border: none; - border-radius: 6px; - font-family: var(--font-sans); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); - background: transparent; - color: var(--text-muted); -} - -.install-tab:hover { - color: var(--text-dim); - background: rgba(255, 255, 255, 0.04); -} - -.install-tab.active { - background: rgba(48, 80, 255, 0.14); - color: var(--primary-light); -} - -.install-tab svg { - flex-shrink: 0; -} - -.install-widget-body { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - font-family: var(--font-mono); - font-size: 13px; - color: var(--text); - overflow-x: auto; -} - -.install-prompt { - color: var(--primary-light); - font-weight: 600; - flex-shrink: 0; - opacity: 0.7; -} - -.install-widget-body code { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; - transition: opacity 0.15s var(--ease-out-quad); -} - -/* --- Code block tabs (install step section) --- */ -.code-tabs { - display: flex; - gap: 2px; -} - -.code-tab { - padding: 3px 10px; - border: none; - border-radius: 4px; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); - background: transparent; - color: var(--text-muted); -} - -.code-tab:hover { - color: var(--text-dim); - background: rgba(255, 255, 255, 0.04); -} - -.code-tab.active { - background: rgba(48, 80, 255, 0.12); - color: var(--primary-light); -} - -.copy-btn { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - font-family: var(--font-sans); - font-size: 12px; - transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); -} -.copy-btn:hover { - color: var(--primary-light); - background: rgba(48, 80, 255, 0.1); -} -.copy-btn:active { - transform: scale(0.95); -} - -.install-note { - font-size: 13px; - color: var(--text-muted); - margin-top: 12px; -} - -.hero-links { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; -} - -.btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 11px 24px; - border-radius: var(--radius); - font-size: 14px; - font-weight: 550; - transition: background 0.25s var(--ease-out-quint), border-color 0.25s var(--ease-out-quad), color 0.2s var(--ease-out-quad), transform 0.25s var(--ease-out-quint); - border: 1px solid transparent; - will-change: transform; -} - -.btn-primary { - background: rgba(48, 80, 255, 0.12); - color: var(--primary-light); - border-color: rgba(48, 80, 255, 0.25); -} -.btn-primary:hover { - background: rgba(48, 80, 255, 0.22); - border-color: rgba(48, 80, 255, 0.4); - color: #fff; -} - -@media (hover: hover) and (pointer: fine) { - .btn-primary:hover { - transform: translateY(-1px); - } -} -.btn:active { - transform: scale(0.97); -} - -/* --- Sections --- */ -.section { - position: relative; - z-index: 1; - padding: 80px 0; -} - -.section-header { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - margin-bottom: 48px; -} - -.section-header h2 { - font-size: 28px; - font-weight: 650; - color: #fff; - letter-spacing: -0.02em; -} - -.section-desc { - color: var(--text-dim); - font-size: 16px; - line-height: 1.7; - max-width: 640px; - margin: 0 auto 40px; - text-align: center; -} - -/* --- Features Grid --- */ -.features-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; -} - -.feature-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - transition: border-color 0.3s var(--ease-out-quad), background 0.3s var(--ease-out-quad), transform 0.3s var(--ease-out-quint); - will-change: transform; -} - -.feature-card:hover { - border-color: var(--border-hover); - background: var(--bg-card-hover); -} - -@media (hover: hover) and (pointer: fine) { - .feature-card:hover { - transform: translateY(-2px); - } -} - -.feature-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} - -.feature-icon { - color: var(--primary-light); - opacity: 0.85; - flex-shrink: 0; - display: flex; - line-height: 0; -} - -.feature-card h3 { - font-size: 15px; - font-weight: 600; - color: #fff; - letter-spacing: -0.01em; -} - -.feature-card p { - font-size: 14px; - color: var(--text-dim); - line-height: 1.65; -} - -/* --- Terminal Demo --- */ -.section-demo { - padding-bottom: 60px; - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); -} - -.terminal-window { - background: #0c0c14; - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - max-width: 800px; - margin: 0 auto; -} - -.terminal-header { - display: flex; - align-items: center; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); - gap: 12px; -} - -.terminal-dots { - display: flex; - gap: 6px; -} - -.dot { - width: 10px; - height: 10px; - border-radius: 50%; -} -.dot-red { background: #ff5f57; } -.dot-yellow { background: #febc2e; } -.dot-green { background: #28c840; } - -.terminal-title { - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-muted); -} - -.terminal-body { - padding: 20px 24px; - height: 340px; - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.7; - white-space: pre-wrap; - overflow-y: auto; - overflow-x: hidden; -} - -.terminal-cursor { - animation: blink 1s step-end infinite; - color: var(--primary-light); - opacity: 0.8; -} - -@keyframes blink { - 0%, 100% { opacity: 0.8; } - 50% { opacity: 0; } -} - -/* Terminal demo colors */ -.t-prompt { color: var(--primary-light); } -.t-cmd { color: #fff; } -.t-dim { color: var(--text-muted); } -.t-text { color: var(--text-dim); } -.t-green { color: #4ade80; } -.t-blue { color: #60a5fa; } -.t-accent { color: var(--primary-light); } -.t-highlight { color: #90B0FF; } -.t-tool { color: var(--text-muted); } - -/* --- Specs Toggle --- */ -.features-more { - text-align: center; - margin-top: 32px; -} - -.more-toggle { - background: none; - border: 1px solid var(--border); - color: var(--text-dim); - font-size: 14px; - font-family: inherit; - padding: 8px 20px; - border-radius: 6px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - transition: color 0.2s var(--ease-out-quad), border-color 0.2s var(--ease-out-quad); -} - -.more-toggle:hover { - color: var(--primary-light); - border-color: var(--primary-light); -} -.more-toggle:active { - transform: scale(0.97); -} - -.more-chevron { - transition: transform 0.3s var(--ease-in-out-cubic); -} - -.more-toggle.open .more-chevron { - transform: rotate(180deg); -} - -.specs-wrapper { - max-height: 0; - overflow: hidden; - transition: max-height 0.4s var(--ease-out-quart), opacity 0.3s var(--ease-out-quad); - opacity: 0; -} - -.specs-wrapper.open { - opacity: 1; -} - -/* --- Specs --- */ -.section-specs { -} - -.specs-list { - max-width: 720px; - margin: 0 auto; - padding-top: 24px; -} - -.spec-row { - display: grid; - grid-template-columns: 120px 1fr; - gap: 24px; - padding: 24px 0; - border-bottom: 1px solid var(--border); -} - -.spec-row:last-child { - border-bottom: none; -} - -.spec-label { - font-size: 14px; - font-weight: 600; - color: var(--primary-light); - padding-top: 2px; -} - -.spec-value { - font-size: 15px; - color: var(--text-dim); - line-height: 1.7; -} - -.spec-value a { - color: var(--text); - border-bottom: 1px solid var(--border-hover); - transition: border-color 0.2s var(--ease-out-quad), color 0.2s var(--ease-out-quad); -} - -.spec-value a:hover { - color: var(--primary-light); - border-color: var(--primary-light); -} - -/* --- Install Section --- */ -.section-install { - border-top: 1px solid var(--border); -} - -.install-steps { - display: grid; - gap: 28px; - max-width: 640px; - margin: 0 auto; -} - -.install-step { - display: flex; - gap: 20px; -} - -.step-number { - flex-shrink: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(48, 80, 255, 0.1); - border: 1px solid rgba(48, 80, 255, 0.2); - border-radius: 50%; - font-size: 14px; - font-weight: 600; - color: var(--primary-light); - margin-top: 2px; -} - -.step-content { - flex: 1; - min-width: 0; -} - -.step-content h4 { - font-size: 16px; - font-weight: 600; - color: #fff; - margin-bottom: 10px; -} - -.step-optional { - font-size: 12px; - font-weight: 400; - color: var(--text-muted); -} - -.step-note { - font-size: 13px; - color: var(--text-muted); - margin-top: 8px; -} - -.code-block { - background: #0c0c14; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; -} - -.code-block-sm { - max-width: 640px; -} - -.code-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 14px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid var(--border); - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-muted); -} - -.code-block pre { - padding: 14px 16px; - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.6; - color: var(--text); - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; -} - -.code-comment { - color: var(--text-muted); -} - -.install-windows { - margin-top: 48px; - padding-top: 32px; - border-top: 1px solid var(--border); - max-width: 640px; - margin-left: auto; - margin-right: auto; -} - -.install-windows p { - font-size: 14px; - color: var(--text-dim); - margin-bottom: 12px; -} - -/* --- Footer --- */ -.footer { - position: relative; - z-index: 1; - padding: 40px 0 32px; - border-top: 1px solid var(--border); -} - -.footer-copy { - text-align: center; - font-size: 13px; - color: var(--text-muted); -} - -.footer-copy a { - color: var(--text-dim); - transition: color 0.2s var(--ease-out-quad); -} - -.footer-copy a:hover { - color: var(--primary-light); -} - -/* --- Scroll Animations --- */ -.fade-in { - opacity: 0; - transform: translateY(20px); - transition: opacity 0.6s var(--ease-out-quart), transform 0.6s var(--ease-out-quart); - will-change: transform, opacity; -} - -.fade-in.visible { - opacity: 1; - transform: translateY(0); -} - -/* --- Responsive --- */ - -/* Clamp ambient glows so they can't cause horizontal scroll */ -@media (max-width: 900px) { - .ambient-glow { display: none; } - - .features-grid { - grid-template-columns: repeat(2, 1fr); - } - -} - -@media (max-width: 640px) { - /* --- Global mobile --- */ - .container { - padding: 0 16px; - } - - .section { - padding: 50px 0; - } - - .section-header { - margin-bottom: 32px; - } - - .section-header h2 { - font-size: 20px; - } - - .section-desc { - font-size: 14px; - } - - /* --- Nav --- */ - .nav-inner { - padding: 0 16px; - } - - .nav-links { - display: none; - } - - .nav-hamburger { - display: flex; - } - - /* --- Hero --- */ - .hero { - padding: 90px 16px 50px; - min-height: auto; - } - - .hero-content { - max-width: 100%; - } - - .hero-badge { - font-size: 11px; - padding: 5px 12px; - margin-bottom: 24px; - } - - .hero-ascii { - font-size: 3.5px; - } - - .hero-title { - font-size: 26px; - margin-bottom: 14px; - } - - .hero-subtitle { - font-size: 14px; - line-height: 1.6; - margin: 0 auto 28px; - } - - .install-widget-body { - font-size: 10px; - padding: 10px 12px; - } - - .install-widget-body code { - overflow: hidden; - text-overflow: ellipsis; - display: block; - } - - .install-widget-header { - padding: 8px 12px; - gap: 10px; - } - - .install-tabs { - gap: 2px; - } - - .install-tab { - padding: 4px 10px; - font-size: 11px; - } - - .install-tab svg { - display: none; - } - - .copy-btn { - padding: 3px 6px; - } - - .copy-btn .copy-text { display: none; } - - .install-note { - font-size: 11px; - } - - .hero-links { - flex-direction: column; - align-items: stretch; - } - - .hero-links .btn { - justify-content: center; - } - - /* --- Grids → single column --- */ - .features-grid { - grid-template-columns: 1fr; - } - - .spec-row { - grid-template-columns: 1fr; - gap: 6px; - padding: 18px 0; - } - - .feature-card { - padding: 16px 18px; - } - - .feature-card p { - font-size: 13px; - line-height: 1.5; - } - - /* --- Terminal demo --- */ - .terminal-body { - font-size: 11px; - padding: 14px; - height: 260px; - } - - /* --- Install steps --- */ - .install-steps { - max-width: 100%; - } - - .install-step { - gap: 14px; - } - - .step-number { - width: 28px; - height: 28px; - font-size: 13px; - } - - .code-block pre { - font-size: 11px; - word-break: break-all; - } - - .install-windows { - max-width: 100%; - } - - /* --- Footer --- */ - .footer { - padding: 32px 0 24px; - } - -} - -/* --- Reduced Motion --- */ -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } - - .fade-in { - opacity: 1; - transform: none; - } - - .hero-ascii { - opacity: 0.85; - } -} - -/* --- Selection --- */ -::selection { - background: rgba(48, 80, 255, 0.25); - color: #fff; -} - -/* --- Scrollbar --- */ -::-webkit-scrollbar { - width: 6px; - height: 6px; -} -::-webkit-scrollbar-track { - background: var(--bg); -} -::-webkit-scrollbar-thumb { - background: var(--border-hover); - border-radius: 3px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--primary-dim); -} From 4683b97d92a40ad8d46181ebf28775325c5043c0 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 15 Apr 2026 23:29:41 -0400 Subject: [PATCH 270/849] Revert "feat: add vercel deployment, remove old landing page (#10686)" This reverts commit 51d5c7648852cdc2674d3b00b43ee0300cca62c3. --- .github/workflows/deploy-site.yml | 25 +- landingpage/apple-touch-icon.png | Bin 0 -> 28150 bytes landingpage/favicon-16x16.png | Bin 0 -> 870 bytes landingpage/favicon-32x32.png | Bin 0 -> 2511 bytes landingpage/favicon.ico | Bin 0 -> 8139 bytes landingpage/hermes-agent-banner.png | Bin 0 -> 12333 bytes landingpage/icon-192.png | Bin 0 -> 29805 bytes landingpage/icon-512.png | Bin 0 -> 137587 bytes landingpage/index.html | 665 +++++++++++++++ landingpage/nous-logo.png | Bin 0 -> 20988 bytes landingpage/script.js | 521 ++++++++++++ landingpage/style.css | 1178 +++++++++++++++++++++++++++ 12 files changed, 2378 insertions(+), 11 deletions(-) create mode 100644 landingpage/apple-touch-icon.png create mode 100644 landingpage/favicon-16x16.png create mode 100644 landingpage/favicon-32x32.png create mode 100644 landingpage/favicon.ico create mode 100644 landingpage/hermes-agent-banner.png create mode 100644 landingpage/icon-192.png create mode 100644 landingpage/icon-512.png create mode 100644 landingpage/index.html create mode 100644 landingpage/nous-logo.png create mode 100644 landingpage/script.js create mode 100644 landingpage/style.css diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 44da745b9..480b236f8 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -1,12 +1,11 @@ name: Deploy Site on: - release: - types: [published] push: branches: [main] paths: - 'website/**' + - 'landingpage/**' - 'skills/**' - 'optional-skills/**' - '.github/workflows/deploy-site.yml' @@ -21,14 +20,8 @@ concurrency: cancel-in-progress: false jobs: - deploy-vercel: - if: github.event_name == 'release' - runs-on: ubuntu-latest - steps: - - name: Trigger Vercel Deploy - run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" - - deploy-docs: + build-and-deploy: + # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest environment: @@ -69,10 +62,20 @@ jobs: run: npm run build working-directory: website + - name: Stage deployment + run: | + mkdir -p _site/docs + # Landing page at root + cp -r landingpage/* _site/ + # Docusaurus at /docs/ + cp -r website/build/* _site/docs/ + # CNAME so GitHub Pages keeps the custom domain between deploys + echo "hermes-agent.nousresearch.com" > _site/CNAME + - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: - path: website/build + path: _site - name: Deploy to GitHub Pages id: deploy diff --git a/landingpage/apple-touch-icon.png b/landingpage/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c5da175f8eb397b579c00678b7687bfd930cdc78 GIT binary patch literal 28150 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 Oy6?^MDf-_L(Ek87?}i%y literal 0 HcmV?d00001 diff --git a/landingpage/hermes-agent-banner.png b/landingpage/hermes-agent-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4a160ceb721402e21ae107cbea45bbd80702b5 GIT binary patch literal 12333 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�!X5p5sf&LloNSTYkUfT-^- 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

ML+Q_oprI)7p(3Ki8 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

sJu{t`y8DsrS@F4j_h= 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 literal 0 HcmV?d00001 diff --git a/landingpage/index.html b/landingpage/index.html new file mode 100644 index 000000000..e24ed11c4 --- /dev/null +++ b/landingpage/index.html @@ -0,0 +1,665 @@ + + + + + + Hermes Agent — An Agent That Grows With You + + + + + + + + + + + + + + + + + + + + + + + +

+
+ + + +
+
+
+ + Open Source • MIT License +
+ + + + +

+ An agent that
+ grows with you. +

+ +

+ It's not a coding copilot tethered to an IDE or a chatbot wrapper + around a single API. It's an autonomous agent that + lives on your server, remembers what it learns, and gets more capable + the longer it runs. +

+ +
+
+
+
+ + + +
+
+ +
+
+
+ $ + curl -fsSL + https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh + | bash + +
+
+

+ Works on Linux, macOS & WSL2 · No prerequisites · Installs + everything automatically +

+
+ + +
+
+ +
+
+
+

Get started in 60 seconds

+
+ +
+
+
1
+
+

Install

+
+
+
+ +
+ +
+
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
+
+

+ Installs uv, Python 3.11, clones the repo, sets up everything. + No sudo needed. +

+
+
+ +
+
2
+
+

Configure

+
+
+ bash + +
+
# Interactive setup wizard
+hermes setup
+
+# Or choose your model
+hermes model
+
+

+ Connect to Nous Portal (OAuth), OpenRouter (API key), or your + own endpoint. +

+
+
+ +
+
3
+
+

Start chatting

+
+
+ bash + +
+
hermes
+
+

+ That's it. Full interactive CLI with tools, memory, and skills. +

+
+
+ +
+
4
+
+

+ Go multi-platform (optional) +

+
+
+ bash + +
+
# Interactive gateway setup wizard
+hermes gateway setup
+
+# Start the messaging gateway
+hermes gateway
+
+# Install as a system service
+hermes gateway install
+
+

+ Walk through connecting Telegram, Discord, Slack, or WhatsApp. + Runs as a systemd service. +

+
+
+ +
+
5
+
+

Keep it up to date

+
+
+ bash + +
+
hermes update
+
+

+ Pulls the latest changes and reinstalls dependencies. Run + anytime to get new features and fixes. +

+
+
+
+ +
+

+ Native Windows support is extremely experimental and unsupported. + Please install + WSL2 + and run Hermes Agent from there. +

+
+
+
+ + +
+
+
+

See it in action

+
+ +
+
+
+ + + +
+ hermes +
+
+
+
+
+ + +
+
+
+

Features

+
+ +
+
+
+
+ + + +
+

Lives Where You Do

+
+

+ Telegram, Discord, Slack, WhatsApp, and CLI from a single gateway + — start on one, pick up on another. +

+
+ +
+
+
+ + + + +
+

Grows the Longer It Runs

+
+

+ Persistent memory and auto-generated skills — it learns your + projects and never forgets how it solved a problem. +

+
+ +
+
+
+ + + + +
+

Scheduled Automations

+
+

+ Natural language cron scheduling for reports, backups, and + briefings — running unattended through the gateway. +

+
+ +
+
+
+ + + + + + +
+

Delegates & Parallelizes

+
+

+ Isolated subagents with their own conversations, terminals, and + Python RPC scripts for zero-context-cost pipelines. +

+
+ +
+
+
+ + + + +
+

Real Sandboxing

+
+

+ Five backends — local, Docker, SSH, Singularity, Modal — with + container hardening and namespace isolation. +

+
+ +
+
+
+ + + + + +
+

Full Web & Browser Control

+
+

+ Web search, browser automation, vision, image generation, + text-to-speech, and multi-model reasoning. +

+
+
+ +
+ +
+ +
+
+
+

Tools

+

+ 40+ built-in — web search, terminal, file system, browser + automation, vision, image generation, text-to-speech, code + execution, subagent delegation, memory, task planning, cron + scheduling, multi-model reasoning, and more. +

+
+ +
+

Platforms

+

+ Telegram, Discord, Slack, WhatsApp, Signal, Email, and CLI — all + from a single gateway. Connect to + Nous Portal, OpenRouter, or any OpenAI-compatible API. +

+
+ +
+

Environments

+

+ Run locally, in Docker, over SSH, on Modal, Daytona, or + Singularity. Container hardening with read-only root, dropped + capabilities, and namespace isolation. +

+
+ +
+

Skills

+

+ 40+ bundled skills covering MLOps, GitHub workflows, research, + and more. The agent creates new skills on the fly and shares + them via the open + agentskills.io + format. Install community skills from + ClawHub, + LobeHub, and GitHub. +

+
+ +
+

Research

+

+ Batch trajectory generation with parallel workers and + checkpointing. Atropos integration for RL training. Export to + ShareGPT for fine-tuning with trajectory compression. +

+
+
+
+
+
+ + + + + + diff --git a/landingpage/nous-logo.png b/landingpage/nous-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cfea9a661337855b90209ab3160d8e07a16e183b GIT binary patch literal 20988 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 literal 0 HcmV?d00001 diff --git a/landingpage/script.js b/landingpage/script.js new file mode 100644 index 000000000..4cd097bdb --- /dev/null +++ b/landingpage/script.js @@ -0,0 +1,521 @@ +// ========================================================================= +// Hermes Agent Landing Page — Interactions +// ========================================================================= + +// --- Platform install commands --- +const PLATFORMS = { + linux: { + command: + "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", + prompt: "$", + note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically", + stepNote: + "Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.", + }, +}; + +function detectPlatform() { + return "linux"; +} + +function switchPlatform(platform) { + const cfg = PLATFORMS[platform]; + if (!cfg) return; + + // Update hero install widget + const commandEl = document.getElementById("install-command"); + const promptEl = document.getElementById("install-prompt"); + const noteEl = document.getElementById("install-note"); + + if (commandEl) commandEl.textContent = cfg.command; + if (promptEl) promptEl.textContent = cfg.prompt; + if (noteEl) noteEl.textContent = cfg.note; + + // Update active tab in hero + document.querySelectorAll(".install-tab").forEach((tab) => { + tab.classList.toggle("active", tab.dataset.platform === platform); + }); + + // Sync the step section tabs too + switchStepPlatform(platform); +} + +function switchStepPlatform(platform) { + const cfg = PLATFORMS[platform]; + if (!cfg) return; + + const commandEl = document.getElementById("step1-command"); + const copyBtn = document.getElementById("step1-copy"); + const noteEl = document.getElementById("step1-note"); + + if (commandEl) commandEl.textContent = cfg.command; + if (copyBtn) copyBtn.setAttribute("data-text", cfg.command); + if (noteEl) noteEl.textContent = cfg.stepNote; + + // Update active tab in step section + document.querySelectorAll(".code-tab").forEach((tab) => { + tab.classList.toggle("active", tab.dataset.platform === platform); + }); +} + +function toggleMobileNav() { + document.getElementById("nav-mobile").classList.toggle("open"); + document.getElementById("nav-hamburger").classList.toggle("open"); +} + +function toggleSpecs() { + const wrapper = document.getElementById("specs-wrapper"); + const btn = document.getElementById("specs-toggle"); + const label = btn.querySelector(".toggle-label"); + const isOpen = wrapper.classList.contains("open"); + + if (isOpen) { + wrapper.style.maxHeight = wrapper.scrollHeight + "px"; + requestAnimationFrame(() => { + wrapper.style.maxHeight = "0"; + }); + wrapper.classList.remove("open"); + btn.classList.remove("open"); + if (label) label.textContent = "More details"; + } else { + wrapper.classList.add("open"); + wrapper.style.maxHeight = wrapper.scrollHeight + "px"; + btn.classList.add("open"); + if (label) label.textContent = "Less"; + wrapper.addEventListener( + "transitionend", + () => { + if (wrapper.classList.contains("open")) { + wrapper.style.maxHeight = "none"; + } + }, + { once: true } + ); + } +} + +// --- Copy to clipboard --- +function copyInstall() { + const text = document.getElementById("install-command").textContent; + navigator.clipboard.writeText(text).then(() => { + const btn = document.querySelector(".install-widget-body .copy-btn"); + const original = btn.querySelector(".copy-text").textContent; + btn.querySelector(".copy-text").textContent = "Copied!"; + btn.style.color = "var(--primary-light)"; + setTimeout(() => { + btn.querySelector(".copy-text").textContent = original; + btn.style.color = ""; + }, 2000); + }); +} + +function copyText(btn) { + const text = btn.getAttribute("data-text"); + navigator.clipboard.writeText(text).then(() => { + const original = btn.textContent; + btn.textContent = "Copied!"; + btn.style.color = "var(--primary-light)"; + setTimeout(() => { + btn.textContent = original; + btn.style.color = ""; + }, 2000); + }); +} + +// --- Scroll-triggered fade-in --- +function initScrollAnimations() { + const elements = document.querySelectorAll( + ".feature-card, .install-step, " + + ".section-header, .terminal-window", + ); + + elements.forEach((el) => el.classList.add("fade-in")); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // Stagger children within grids + const parent = entry.target.parentElement; + if (parent) { + const siblings = parent.querySelectorAll(".fade-in"); + let idx = Array.from(siblings).indexOf(entry.target); + if (idx < 0) idx = 0; + setTimeout(() => { + entry.target.classList.add("visible"); + }, idx * 60); + } else { + entry.target.classList.add("visible"); + } + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.1, rootMargin: "0px 0px -40px 0px" }, + ); + + elements.forEach((el) => observer.observe(el)); +} + +// --- Terminal Demo --- +const CURSOR = ''; + +const demoSequence = [ + { type: "prompt", text: "❯ " }, + { + type: "type", + text: "Research the latest approaches to GRPO training and write a summary", + delay: 30, + }, + { type: "pause", ms: 600 }, + { + type: "output", + lines: [ + "", + ' web_search "GRPO reinforcement learning 2026" 1.2s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' web_extract arxiv.org/abs/2402.03300 3.1s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' web_search "GRPO vs PPO ablation results" 0.9s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' web_extract huggingface.co/blog/grpo 2.8s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' write_file ~/research/grpo-summary.md 0.1s', + ], + }, + { type: "pause", ms: 500 }, + { + type: "output", + lines: [ + "", + 'Done! I\'ve written a summary covering:', + "", + ' GRPO\'s group-relative advantage (no critic model needed)', + ' Comparison with PPO/DPO on reasoning benchmarks', + ' Implementation notes for Axolotl and TRL', + "", + 'Saved to ~/research/grpo-summary.md', + ], + }, + { type: "pause", ms: 2500 }, + + { type: "clear" }, + { type: "prompt", text: "❯ " }, + { + type: "type", + text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues", + delay: 30, + }, + { type: "pause", ms: 600 }, + { + type: "output", + lines: [ + "", + ' delegate_task "review PR #42 changes" 2.1s', + ], + }, + { type: "pause", ms: 500 }, + { + type: "output", + lines: [ + ' git diff main..pr-42 0.4s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' patch tools/registry.py 0.1s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' python -m pytest tests/ -x 3.2s', + ], + }, + { type: "pause", ms: 400 }, + { + type: "output", + lines: [ + ' git commit -m "fix: handle empty tool schemas" 0.3s', + ], + }, + { type: "pause", ms: 500 }, + { + type: "output", + lines: [ + "", + 'Found 2 issues in the PR and fixed both:', + "", + ' Empty tool schema crash in registry.py — added guard', + ' Missing error handling in delegate_tool.py — added try/except', + "", + 'Tests pass. Committed the fix and pushed to the PR branch.', + 'I also saved a skill for this PR review pattern.', + ], + }, + { type: "pause", ms: 2500 }, + + { type: "clear" }, + { type: "prompt", text: "❯ " }, + { + type: "type", + text: "How did we fix that Docker networking issue?", + delay: 35, + }, + { type: "pause", ms: 500 }, + { + type: "output", + lines: [ + "", + ' session_search "Docker networking" 1.4s', + ], + }, + { type: "pause", ms: 500 }, + { + type: "output", + lines: [ + "", + 'Found it — from a session on February 12th:', + "", + 'The containers couldn\'t reach each other because the compose', + 'file was using the default bridge network. We switched to a', + 'custom network with driver: overlay, added explicit', + 'aliases, and set dns: 8.8.8.8 as a fallback.', + "", + 'The fix was committed in docker-compose.prod.yml.', + ], + }, + { type: "pause", ms: 3000 }, +]; + +class TerminalDemo { + constructor(container) { + this.container = container; + this.running = false; + this.content = ""; + } + + async start() { + if (this.running) return; + this.running = true; + + while (this.running) { + for (const step of demoSequence) { + if (!this.running) return; + await this.execute(step); + } + this.clear(); + await this.sleep(1000); + } + } + + stop() { + this.running = false; + } + + async execute(step) { + switch (step.type) { + case "prompt": + this.append(`${step.text}`); + break; + case "type": + for (const char of step.text) { + if (!this.running) return; + this.append(`${char}`); + await this.sleep(step.delay || 30); + } + break; + case "output": + for (const line of step.lines) { + if (!this.running) return; + this.append("\n" + line); + await this.sleep(50); + } + break; + case "pause": + await this.sleep(step.ms); + break; + case "clear": + this.clear(); + break; + } + } + + append(html) { + this.content += html; + this.render(); + } + + render() { + this.container.innerHTML = this.content + CURSOR; + this.container.scrollTop = this.container.scrollHeight; + } + + clear() { + this.content = ""; + this.container.innerHTML = ""; + } + + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// --- Noise Overlay (ported from hermes-chat NoiseOverlay) --- +function initNoiseOverlay() { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + if (typeof THREE === "undefined") return; + + const canvas = document.getElementById("noise-overlay"); + if (!canvas) return; + + const vertexShader = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `; + + const fragmentShader = ` + uniform vec2 uRes; + uniform float uDpr, uSize, uDensity, uOpacity; + uniform vec3 uColor; + varying vec2 vUv; + + float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + void main() { + float n = hash(floor(vUv * uRes / (uSize * uDpr))); + gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity; + } + `; + + function hexToVec3(hex) { + const c = hex.replace("#", ""); + return new THREE.Vector3( + parseInt(c.substring(0, 2), 16) / 255, + parseInt(c.substring(2, 4), 16) / 255, + parseInt(c.substring(4, 6), 16) / 255, + ); + } + + const renderer = new THREE.WebGLRenderer({ + alpha: true, + canvas, + premultipliedAlpha: false, + }); + renderer.setClearColor(0x000000, 0); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const geo = new THREE.PlaneGeometry(2, 2); + + const mat = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + transparent: true, + uniforms: { + uColor: { value: hexToVec3("#8090BB") }, + uDensity: { value: 0.1 }, + uDpr: { value: 1 }, + uOpacity: { value: 0.4 }, + uRes: { value: new THREE.Vector2() }, + uSize: { value: 1.0 }, + }, + }); + + scene.add(new THREE.Mesh(geo, mat)); + + function resize() { + const dpr = window.devicePixelRatio; + const w = window.innerWidth; + const h = window.innerHeight; + renderer.setSize(w, h); + renderer.setPixelRatio(dpr); + mat.uniforms.uRes.value.set(w * dpr, h * dpr); + mat.uniforms.uDpr.value = dpr; + } + + resize(); + window.addEventListener("resize", resize); + + function loop() { + requestAnimationFrame(loop); + renderer.render(scene, camera); + } + loop(); +} + +// --- Initialize --- +document.addEventListener("DOMContentLoaded", () => { + const detectedPlatform = detectPlatform(); + switchPlatform(detectedPlatform); + + initScrollAnimations(); + initNoiseOverlay(); + + const terminalEl = document.getElementById("terminal-demo"); + + if (terminalEl) { + const demo = new TerminalDemo(terminalEl); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + demo.start(); + } else { + demo.stop(); + } + }); + }, + { threshold: 0.3 }, + ); + + observer.observe(document.querySelector(".terminal-window")); + } + + const nav = document.querySelector(".nav"); + let ticking = false; + window.addEventListener("scroll", () => { + if (!ticking) { + requestAnimationFrame(() => { + if (window.scrollY > 50) { + nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)"; + } else { + nav.style.borderBottomColor = ""; + } + ticking = false; + }); + ticking = true; + } + }); +}); diff --git a/landingpage/style.css b/landingpage/style.css new file mode 100644 index 000000000..30334df0d --- /dev/null +++ b/landingpage/style.css @@ -0,0 +1,1178 @@ +/* ========================================================================= + Hermes Agent Landing Page + Colors: Nous Blue (#3050FF) palette + ========================================================================= */ + +/* --- Reset & Base --- */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #3050FF; + --primary-light: #5070FF; + --primary-dim: #2040CC; + --primary-dark: #1E30AA; + --bg: #0A0E1A; + --bg-card: #12182A; + --bg-card-hover: #1A2240; + --border: rgba(48, 80, 255, 0.1); + --border-hover: rgba(48, 80, 255, 0.22); + --text: #E8ECFF; + --text-dim: #8090BB; + --text-muted: #506090; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --container: 1080px; + --radius: 12px; + --radius-sm: 8px; + + --ease-in-quad: cubic-bezier(.55, .085, .68, .53); + --ease-in-cubic: cubic-bezier(.550, .055, .675, .19); + --ease-in-quart: cubic-bezier(.895, .03, .685, .22); + --ease-in-quint: cubic-bezier(.755, .05, .855, .06); + --ease-in-expo: cubic-bezier(.95, .05, .795, .035); + --ease-in-circ: cubic-bezier(.6, .04, .98, .335); + + --ease-out-quad: cubic-bezier(.25, .46, .45, .94); + --ease-out-cubic: cubic-bezier(.215, .61, .355, 1); + --ease-out-quart: cubic-bezier(.165, .84, .44, 1); + --ease-out-quint: cubic-bezier(.23, 1, .32, 1); + --ease-out-expo: cubic-bezier(.19, 1, .22, 1); + --ease-out-circ: cubic-bezier(.075, .82, .165, 1); + + --ease-in-out-quad: cubic-bezier(.455, .03, .515, .955); + --ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1); + --ease-in-out-quart: cubic-bezier(.77, 0, .175, 1); + --ease-in-out-quint: cubic-bezier(.86, 0, .07, 1); + --ease-in-out-expo: cubic-bezier(1, 0, 0, 1); + --ease-in-out-circ: cubic-bezier(.785, .135, .15, .86); +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + line-height: 1.6; + overflow-x: hidden; + width: 100%; + max-width: 100vw; + background-image: radial-gradient(rgba(48, 80, 255, 0.04) 1px, transparent 1px); + background-size: 32px 32px; +} + +a { + color: var(--primary); + text-decoration: none; + transition: color 0.2s var(--ease-out-quad); +} +a:hover { + color: var(--primary-light); +} + +strong { + color: #fff; + font-weight: 600; +} + +/* --- Noise Overlay --- */ +#noise-overlay { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 50; + pointer-events: none; + mix-blend-mode: soft-light; +} + +/* --- Ambient Glow --- */ +.ambient-glow { + position: fixed; + pointer-events: none; + z-index: 0; + border-radius: 50%; + filter: blur(120px); + opacity: 0.15; +} +.glow-1 { + width: 600px; + height: 600px; + background: var(--primary); + top: -200px; + left: -200px; + opacity: 0.08; +} +.glow-2 { + width: 500px; + height: 500px; + background: var(--primary-dim); + bottom: 20%; + right: -150px; + opacity: 0.06; +} + +/* --- Container --- */ +.container { + max-width: var(--container); + margin: 0 auto; + padding: 0 24px; +} + +/* --- Navigation --- */ +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(7, 7, 13, 0.8); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + transition: border-bottom-color 0.3s var(--ease-out-quad); +} + +.nav-inner { + max-width: var(--container); + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-logo { + display: flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 600; + font-size: 15px; + transition: color 0.2s var(--ease-out-quad); +} +.nav-logo:hover { color: var(--primary-light); } + +.nav-nous-logo { + width: 22px; + height: 22px; + border-radius: 4px; +} + +.nav-by { + font-weight: 400; + color: var(--text-muted); + font-size: 13px; +} + +.nav-links { + display: flex; + align-items: center; + gap: 28px; +} + +.nav-links a { + color: var(--text-dim); + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + transition: color 0.2s var(--ease-out-quad); +} +.nav-links a:hover { color: #fff; } + +.external-icon { opacity: 0.4; } + +/* --- Hamburger & Mobile Nav --- */ +.nav-hamburger { + display: none; + background: none; + border: none; + cursor: pointer; + padding: 6px; + width: 34px; + height: 34px; + flex-direction: column; + justify-content: center; + gap: 5px; +} + +.hamburger-bar { + display: block; + width: 20px; + height: 2px; + background: var(--text-dim); + border-radius: 1px; + transition: transform 0.25s var(--ease-out-quint), opacity 0.2s var(--ease-out-quad); + transform-origin: center; +} + +.nav-hamburger.open .hamburger-bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} + +.nav-hamburger.open .hamburger-bar:nth-child(2) { + opacity: 0; +} + +.nav-hamburger.open .hamburger-bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +.nav-mobile { + display: none; +} + +.nav-mobile.open { + display: flex; + flex-direction: column; + position: absolute; + top: 60px; + left: 0; + right: 0; + background: rgba(7, 7, 13, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + padding: 16px 24px; + gap: 16px; +} + +.nav-mobile a { + color: var(--text-dim); + font-size: 15px; + font-weight: 500; + padding: 4px 0; + transition: color 0.2s var(--ease-out-quad); +} + +.nav-mobile a:hover { + color: #fff; +} + +/* --- Hero --- */ +.hero { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 120px 24px 80px; + text-align: center; +} + +.hero-content { + max-width: 760px; +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + background: rgba(48, 80, 255, 0.08); + border: 1px solid rgba(48, 80, 255, 0.18); + border-radius: 100px; + font-size: 13px; + color: var(--text-dim); + margin-bottom: 32px; + font-weight: 450; +} + +.badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); + display: inline-block; + animation: pulse-dot 2s var(--ease-in-out-quad) infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.hero-ascii { + margin-bottom: 28px; + font-family: 'JetBrains Mono', monospace; + font-variant-ligatures: none; + font-size: clamp(4px, 0.95vw, 11px); + line-height: 1.15; + color: var(--primary-light); + text-align: center; + text-shadow: 0 0 20px rgba(48, 80, 255, 0.3); + opacity: 0.85; + transition: opacity 0.3s var(--ease-out-cubic); + overflow-x: auto; + white-space: pre; +} + +.hero-ascii:hover { + opacity: 1; +} + +.hero-title { + font-size: clamp(36px, 6vw, 56px); + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.03em; + margin-bottom: 20px; + color: #fff; +} + +.hero-gradient { + background: linear-gradient(135deg, var(--primary), var(--primary-light), #90B0FF); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-subtitle { + font-size: 17px; + line-height: 1.7; + color: var(--text-dim); + max-width: 620px; + margin: 0 auto 36px; +} + +.hero-install { + margin-bottom: 32px; +} + +/* --- Install Widget (hero tabbed installer) --- */ +.install-widget { + max-width: 740px; + margin: 0 auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: border-color 0.3s var(--ease-out-quad); +} + +.install-widget:hover { + border-color: var(--border-hover); +} + +.install-widget-header { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--border); +} + +.install-dots { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.install-dots .dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.install-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.install-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 14px; + border: none; + border-radius: 6px; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); + background: transparent; + color: var(--text-muted); +} + +.install-tab:hover { + color: var(--text-dim); + background: rgba(255, 255, 255, 0.04); +} + +.install-tab.active { + background: rgba(48, 80, 255, 0.14); + color: var(--primary-light); +} + +.install-tab svg { + flex-shrink: 0; +} + +.install-widget-body { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text); + overflow-x: auto; +} + +.install-prompt { + color: var(--primary-light); + font-weight: 600; + flex-shrink: 0; + opacity: 0.7; +} + +.install-widget-body code { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + transition: opacity 0.15s var(--ease-out-quad); +} + +/* --- Code block tabs (install step section) --- */ +.code-tabs { + display: flex; + gap: 2px; +} + +.code-tab { + padding: 3px 10px; + border: none; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); + background: transparent; + color: var(--text-muted); +} + +.code-tab:hover { + color: var(--text-dim); + background: rgba(255, 255, 255, 0.04); +} + +.code-tab.active { + background: rgba(48, 80, 255, 0.12); + color: var(--primary-light); +} + +.copy-btn { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + font-family: var(--font-sans); + font-size: 12px; + transition: color 0.2s var(--ease-out-quad), background 0.2s var(--ease-out-quad); +} +.copy-btn:hover { + color: var(--primary-light); + background: rgba(48, 80, 255, 0.1); +} +.copy-btn:active { + transform: scale(0.95); +} + +.install-note { + font-size: 13px; + color: var(--text-muted); + margin-top: 12px; +} + +.hero-links { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 24px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 550; + transition: background 0.25s var(--ease-out-quint), border-color 0.25s var(--ease-out-quad), color 0.2s var(--ease-out-quad), transform 0.25s var(--ease-out-quint); + border: 1px solid transparent; + will-change: transform; +} + +.btn-primary { + background: rgba(48, 80, 255, 0.12); + color: var(--primary-light); + border-color: rgba(48, 80, 255, 0.25); +} +.btn-primary:hover { + background: rgba(48, 80, 255, 0.22); + border-color: rgba(48, 80, 255, 0.4); + color: #fff; +} + +@media (hover: hover) and (pointer: fine) { + .btn-primary:hover { + transform: translateY(-1px); + } +} +.btn:active { + transform: scale(0.97); +} + +/* --- Sections --- */ +.section { + position: relative; + z-index: 1; + padding: 80px 0; +} + +.section-header { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 48px; +} + +.section-header h2 { + font-size: 28px; + font-weight: 650; + color: #fff; + letter-spacing: -0.02em; +} + +.section-desc { + color: var(--text-dim); + font-size: 16px; + line-height: 1.7; + max-width: 640px; + margin: 0 auto 40px; + text-align: center; +} + +/* --- Features Grid --- */ +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + transition: border-color 0.3s var(--ease-out-quad), background 0.3s var(--ease-out-quad), transform 0.3s var(--ease-out-quint); + will-change: transform; +} + +.feature-card:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); +} + +@media (hover: hover) and (pointer: fine) { + .feature-card:hover { + transform: translateY(-2px); + } +} + +.feature-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.feature-icon { + color: var(--primary-light); + opacity: 0.85; + flex-shrink: 0; + display: flex; + line-height: 0; +} + +.feature-card h3 { + font-size: 15px; + font-weight: 600; + color: #fff; + letter-spacing: -0.01em; +} + +.feature-card p { + font-size: 14px; + color: var(--text-dim); + line-height: 1.65; +} + +/* --- Terminal Demo --- */ +.section-demo { + padding-bottom: 60px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.terminal-window { + background: #0c0c14; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + max-width: 800px; + margin: 0 auto; +} + +.terminal-header { + display: flex; + align-items: center; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--border); + gap: 12px; +} + +.terminal-dots { + display: flex; + gap: 6px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; +} +.dot-red { background: #ff5f57; } +.dot-yellow { background: #febc2e; } +.dot-green { background: #28c840; } + +.terminal-title { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); +} + +.terminal-body { + padding: 20px 24px; + height: 340px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.7; + white-space: pre-wrap; + overflow-y: auto; + overflow-x: hidden; +} + +.terminal-cursor { + animation: blink 1s step-end infinite; + color: var(--primary-light); + opacity: 0.8; +} + +@keyframes blink { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 0; } +} + +/* Terminal demo colors */ +.t-prompt { color: var(--primary-light); } +.t-cmd { color: #fff; } +.t-dim { color: var(--text-muted); } +.t-text { color: var(--text-dim); } +.t-green { color: #4ade80; } +.t-blue { color: #60a5fa; } +.t-accent { color: var(--primary-light); } +.t-highlight { color: #90B0FF; } +.t-tool { color: var(--text-muted); } + +/* --- Specs Toggle --- */ +.features-more { + text-align: center; + margin-top: 32px; +} + +.more-toggle { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 14px; + font-family: inherit; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: color 0.2s var(--ease-out-quad), border-color 0.2s var(--ease-out-quad); +} + +.more-toggle:hover { + color: var(--primary-light); + border-color: var(--primary-light); +} +.more-toggle:active { + transform: scale(0.97); +} + +.more-chevron { + transition: transform 0.3s var(--ease-in-out-cubic); +} + +.more-toggle.open .more-chevron { + transform: rotate(180deg); +} + +.specs-wrapper { + max-height: 0; + overflow: hidden; + transition: max-height 0.4s var(--ease-out-quart), opacity 0.3s var(--ease-out-quad); + opacity: 0; +} + +.specs-wrapper.open { + opacity: 1; +} + +/* --- Specs --- */ +.section-specs { +} + +.specs-list { + max-width: 720px; + margin: 0 auto; + padding-top: 24px; +} + +.spec-row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 24px; + padding: 24px 0; + border-bottom: 1px solid var(--border); +} + +.spec-row:last-child { + border-bottom: none; +} + +.spec-label { + font-size: 14px; + font-weight: 600; + color: var(--primary-light); + padding-top: 2px; +} + +.spec-value { + font-size: 15px; + color: var(--text-dim); + line-height: 1.7; +} + +.spec-value a { + color: var(--text); + border-bottom: 1px solid var(--border-hover); + transition: border-color 0.2s var(--ease-out-quad), color 0.2s var(--ease-out-quad); +} + +.spec-value a:hover { + color: var(--primary-light); + border-color: var(--primary-light); +} + +/* --- Install Section --- */ +.section-install { + border-top: 1px solid var(--border); +} + +.install-steps { + display: grid; + gap: 28px; + max-width: 640px; + margin: 0 auto; +} + +.install-step { + display: flex; + gap: 20px; +} + +.step-number { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(48, 80, 255, 0.1); + border: 1px solid rgba(48, 80, 255, 0.2); + border-radius: 50%; + font-size: 14px; + font-weight: 600; + color: var(--primary-light); + margin-top: 2px; +} + +.step-content { + flex: 1; + min-width: 0; +} + +.step-content h4 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin-bottom: 10px; +} + +.step-optional { + font-size: 12px; + font-weight: 400; + color: var(--text-muted); +} + +.step-note { + font-size: 13px; + color: var(--text-muted); + margin-top: 8px; +} + +.code-block { + background: #0c0c14; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.code-block-sm { + max-width: 640px; +} + +.code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); +} + +.code-block pre { + padding: 14px 16px; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--text); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.code-comment { + color: var(--text-muted); +} + +.install-windows { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid var(--border); + max-width: 640px; + margin-left: auto; + margin-right: auto; +} + +.install-windows p { + font-size: 14px; + color: var(--text-dim); + margin-bottom: 12px; +} + +/* --- Footer --- */ +.footer { + position: relative; + z-index: 1; + padding: 40px 0 32px; + border-top: 1px solid var(--border); +} + +.footer-copy { + text-align: center; + font-size: 13px; + color: var(--text-muted); +} + +.footer-copy a { + color: var(--text-dim); + transition: color 0.2s var(--ease-out-quad); +} + +.footer-copy a:hover { + color: var(--primary-light); +} + +/* --- Scroll Animations --- */ +.fade-in { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s var(--ease-out-quart), transform 0.6s var(--ease-out-quart); + will-change: transform, opacity; +} + +.fade-in.visible { + opacity: 1; + transform: translateY(0); +} + +/* --- Responsive --- */ + +/* Clamp ambient glows so they can't cause horizontal scroll */ +@media (max-width: 900px) { + .ambient-glow { display: none; } + + .features-grid { + grid-template-columns: repeat(2, 1fr); + } + +} + +@media (max-width: 640px) { + /* --- Global mobile --- */ + .container { + padding: 0 16px; + } + + .section { + padding: 50px 0; + } + + .section-header { + margin-bottom: 32px; + } + + .section-header h2 { + font-size: 20px; + } + + .section-desc { + font-size: 14px; + } + + /* --- Nav --- */ + .nav-inner { + padding: 0 16px; + } + + .nav-links { + display: none; + } + + .nav-hamburger { + display: flex; + } + + /* --- Hero --- */ + .hero { + padding: 90px 16px 50px; + min-height: auto; + } + + .hero-content { + max-width: 100%; + } + + .hero-badge { + font-size: 11px; + padding: 5px 12px; + margin-bottom: 24px; + } + + .hero-ascii { + font-size: 3.5px; + } + + .hero-title { + font-size: 26px; + margin-bottom: 14px; + } + + .hero-subtitle { + font-size: 14px; + line-height: 1.6; + margin: 0 auto 28px; + } + + .install-widget-body { + font-size: 10px; + padding: 10px 12px; + } + + .install-widget-body code { + overflow: hidden; + text-overflow: ellipsis; + display: block; + } + + .install-widget-header { + padding: 8px 12px; + gap: 10px; + } + + .install-tabs { + gap: 2px; + } + + .install-tab { + padding: 4px 10px; + font-size: 11px; + } + + .install-tab svg { + display: none; + } + + .copy-btn { + padding: 3px 6px; + } + + .copy-btn .copy-text { display: none; } + + .install-note { + font-size: 11px; + } + + .hero-links { + flex-direction: column; + align-items: stretch; + } + + .hero-links .btn { + justify-content: center; + } + + /* --- Grids → single column --- */ + .features-grid { + grid-template-columns: 1fr; + } + + .spec-row { + grid-template-columns: 1fr; + gap: 6px; + padding: 18px 0; + } + + .feature-card { + padding: 16px 18px; + } + + .feature-card p { + font-size: 13px; + line-height: 1.5; + } + + /* --- Terminal demo --- */ + .terminal-body { + font-size: 11px; + padding: 14px; + height: 260px; + } + + /* --- Install steps --- */ + .install-steps { + max-width: 100%; + } + + .install-step { + gap: 14px; + } + + .step-number { + width: 28px; + height: 28px; + font-size: 13px; + } + + .code-block pre { + font-size: 11px; + word-break: break-all; + } + + .install-windows { + max-width: 100%; + } + + /* --- Footer --- */ + .footer { + padding: 32px 0 24px; + } + +} + +/* --- Reduced Motion --- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .fade-in { + opacity: 1; + transform: none; + } + + .hero-ascii { + opacity: 0.85; + } +} + +/* --- Selection --- */ +::selection { + background: rgba(48, 80, 255, 0.25); + color: #fff; +} + +/* --- Scrollbar --- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--border-hover); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--primary-dim); +} From 9f759d177125404f8123bcd9b54fc072eb1225b1 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 15 Apr 2026 23:33:03 -0400 Subject: [PATCH 271/849] fix: match the url as prev --- .github/workflows/deploy-site.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 44da745b9..3e78bc61b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -69,10 +69,15 @@ jobs: run: npm run build working-directory: website + - name: Stage deployment + run: | + mkdir -p _site/docs + cp -r website/build/* _site/docs/ + - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: - path: website/build + path: _site - name: Deploy to GitHub Pages id: deploy From cb31732c4f3a2326e385194184b2215744bf6801 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 23:29:00 -0500 Subject: [PATCH 272/849] 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 000000000..b7f895539 --- /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 947af602a..ee1a70978 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 eb5a03583..b20804380 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 000000000..cb980248a --- /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 000000000..4627244e3 --- /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 000000000..5fa817f5a --- /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 000000000..221a7e5ae --- /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 0b1d4e95b..ef3a7aba0 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 4f91a386b..de6e71e2e 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 000000000..b3efa48af --- /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 ff5bf0d6c8634cd91d0d06c44e0e3a7254072f5a Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Wed, 15 Apr 2026 20:01:29 -0700 Subject: [PATCH 273/849] =?UTF-8?q?fix(tests):=20resolve=20CI=20test=20fai?= =?UTF-8?q?lures=20=E2=80=94=20pool=20auto-seeding,=20stale=20assertions,?= =?UTF-8?q?=20mock=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #10643 by kshitijk4poor, updated for current main. Root causes fixed: 1. Telegram xdist mock pollution — new tests/gateway/conftest.py with shared mock that runs at collection time (prevents ChatType=None caching) 2. VIRTUAL_ENV env var leak — monkeypatch.delenv in _detect_venv_dir tests 3. Copilot base_url missing — add fallback in _resolve_runtime_from_pool_entry 4. Stale vision model assertion — zai now uses glm-5v-turbo 5. Reasoning item id intentionally stripped — assert 'id' not in (store=False) 6. Context length warning unreachable — pass base_url to AIAgent in test 7. Kimi provider label updated — 'Kimi / Kimi Coding Plan' matches models.py 8. Google Workspace calendar tests — rewritten for current production code, properly mock subprocess on api_module, removed stale +agenda assertions 9. Credential pool auto-seeding — mock _select_pool_entry / _resolve_auto / _import_codex_cli_tokens to prevent real credentials from leaking into tests --- hermes_cli/runtime_provider.py | 1 + tests/agent/test_auxiliary_client.py | 23 +++++-- .../test_auxiliary_named_custom_providers.py | 2 +- tests/agent/test_credential_pool.py | 5 ++ tests/gateway/conftest.py | 66 +++++++++++++++++++ tests/hermes_cli/test_gateway_service.py | 4 ++ tests/hermes_cli/test_model_validation.py | 2 +- .../test_invalid_context_length_warning.py | 3 + tests/run_agent/test_provider_parity.py | 5 +- tests/skills/test_google_workspace_api.py | 49 ++++++++------ 10 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 tests/gateway/conftest.py diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index bdfcfb09d..33b35562f 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -167,6 +167,7 @@ def _resolve_runtime_from_pool_entry( api_mode = "chat_completions" elif provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) + base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Honour model.base_url from config.yaml when the configured provider diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 3b44cba4d..2cf64c33b 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -89,7 +89,8 @@ class TestReadCodexAccessToken: hermes_home.mkdir(parents=True, exist_ok=True) (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() + with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + result = _read_codex_access_token() assert result is None def test_empty_token_returns_none(self, tmp_path, monkeypatch): @@ -146,7 +147,8 @@ class TestReadCodexAccessToken: }, })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() + with patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + result = _read_codex_access_token() assert result is None, "Expired JWT should return None" def test_valid_jwt_returns_token(self, tmp_path, monkeypatch): @@ -585,7 +587,10 @@ class TestGetTextAuxiliaryClient: assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" def test_codex_fallback_when_nothing_else(self, codex_auth_dir): - with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "gpt-5.2-codex" @@ -623,17 +628,21 @@ class TestGetTextAuxiliaryClient: monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) - with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ - patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \ - patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)): + with patch("agent.auxiliary_client._resolve_auto", return_value=(None, None)): client, model = get_text_auxiliary_client() assert client is None assert model is None - def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self): + def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self, monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \ patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \ + patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 224910ac4..437a6c400 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -232,7 +232,7 @@ class TestResolveVisionProviderClientModelNormalization: assert provider == "zai" assert client is not None - assert model == "glm-5.1" + assert model == "glm-5v-turbo" # zai has dedicated vision model in _PROVIDER_VISION_MODELS class TestVisionPathApiMode: diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index c11782f69..7ec0385b6 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -252,6 +252,11 @@ def test_exhausted_402_entry_resets_after_one_hour(tmp_path, monkeypatch): def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + # Prevent auto-seeding from Codex CLI tokens on the host + monkeypatch.setattr( + "hermes_cli.auth._import_codex_cli_tokens", + lambda: None, + ) _write_auth_store( tmp_path, { diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py new file mode 100644 index 000000000..5fd8d86fe --- /dev/null +++ b/tests/gateway/conftest.py @@ -0,0 +1,66 @@ +"""Shared fixtures for gateway tests. + +The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of +the ``telegram`` package is registered in :data:`sys.modules` **before** +any test file triggers ``from gateway.platforms.telegram import ...``. + +Without this, ``pytest-xdist`` workers that happen to collect +``test_telegram_caption_merge.py`` (bare top-level import, no per-file +mock) first will cache ``ChatType = None`` from the production +ImportError fallback, causing 30+ downstream test failures wherever +``ChatType.GROUP`` / ``ChatType.SUPERGROUP`` is accessed. + +Individual test files may still call their own ``_ensure_telegram_mock`` +— it short-circuits when the mock is already present. +""" + +import sys +from unittest.mock import MagicMock + + +def _ensure_telegram_mock() -> None: + """Install a comprehensive telegram mock in sys.modules. + + Idempotent — skips when the real library is already imported. + Uses ``sys.modules[name] = mod`` (overwrite) instead of + ``setdefault`` so it wins even if a partial/broken import + already cached a module with ``ChatType = None``. + """ + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return # Real library is installed — nothing to mock + + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN = "Markdown" + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ParseMode.HTML = "HTML" + mod.constants.ChatType.PRIVATE = "private" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + + # Real exception classes so ``except (NetworkError, ...)`` clauses + # in production code don't blow up with TypeError. + mod.error.NetworkError = type("NetworkError", (OSError,), {}) + mod.error.TimedOut = type("TimedOut", (OSError,), {}) + mod.error.BadRequest = type("BadRequest", (Exception,), {}) + mod.error.Forbidden = type("Forbidden", (Exception,), {}) + mod.error.InvalidToken = type("InvalidToken", (Exception,), {}) + mod.error.RetryAfter = type("RetryAfter", (Exception,), {"retry_after": 1}) + mod.error.Conflict = type("Conflict", (Exception,), {}) + + # Update.ALL_TYPES used in start_polling() + mod.Update.ALL_TYPES = [] + + for name in ( + "telegram", + "telegram.ext", + "telegram.constants", + "telegram.request", + ): + sys.modules[name] = mod + sys.modules["telegram.error"] = mod.error + + +# Run at collection time — before any test file's module-level imports. +_ensure_telegram_mock() diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index fedbdf4d1..e624a6734 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -613,6 +613,7 @@ class TestDetectVenvDir: # Not inside a virtualenv monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) dot_venv = tmp_path / ".venv" @@ -624,6 +625,7 @@ class TestDetectVenvDir: def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) venv = tmp_path / "venv" @@ -635,6 +637,7 @@ class TestDetectVenvDir: def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) (tmp_path / ".venv").mkdir() @@ -646,6 +649,7 @@ class TestDetectVenvDir: def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.prefix", "/usr") monkeypatch.setattr("sys.base_prefix", "/usr") + monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) result = gateway_cli._detect_venv_dir() diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 5ed6b9d54..cd0947708 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -163,7 +163,7 @@ class TestNormalizeProvider: class TestProviderLabel: def test_known_labels_and_auto(self): assert provider_label("anthropic") == "Anthropic" - assert provider_label("kimi") == "Kimi / Moonshot" + assert provider_label("kimi") == "Kimi / Kimi Coding Plan" assert provider_label("copilot") == "GitHub Copilot" assert provider_label("copilot-acp") == "GitHub Copilot ACP" assert provider_label("auto") == "Auto" diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py index 1ed72c951..14b2e0f2a 100644 --- a/tests/run_agent/test_invalid_context_length_warning.py +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -9,6 +9,8 @@ def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus- if custom_providers is not None: cfg["custom_providers"] = custom_providers + base_url = model_cfg.get("base_url", "") + with ( patch("hermes_cli.config.load_config", return_value=cfg), patch("agent.model_metadata.get_model_context_length", return_value=128_000), @@ -21,6 +23,7 @@ def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus- agent = AIAgent( model=model, api_key="test-key-1234567890", + base_url=base_url, quiet_mode=True, skip_context_files=True, skip_memory=True, diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c0c62b01b..1817e44a6 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -805,7 +805,10 @@ class TestCodexReasoningPreflight: reasoning_items = [i for i in normalized if i.get("type") == "reasoning"] assert len(reasoning_items) == 1 assert reasoning_items[0]["encrypted_content"] == "abc123encrypted" - assert reasoning_items[0]["id"] == "r_001" + # Note: "id" is intentionally excluded from normalized output — + # with store=False the API returns 404 on server-side id resolution. + # The id is only used for local deduplication via seen_ids. + assert "id" not in reasoning_items[0] assert reasoning_items[0]["summary"] == [{"type": "summary_text", "text": "Thinking about it"}] def test_reasoning_item_without_id(self, monkeypatch): diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 034dd29c0..655f32f52 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -46,6 +46,12 @@ def api_module(monkeypatch, tmp_path): module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) + # Ensure the gws CLI code path is taken even when the binary isn't + # installed (CI). Without this, calendar_list() falls through to the + # Python SDK path which imports ``googleapiclient`` — not in deps. + module._gws_binary = lambda: "/usr/bin/gws" + # Bypass authentication check — no real token file in CI. + module._ensure_authenticated = lambda: None return module @@ -124,35 +130,41 @@ def test_bridge_main_injects_token_env(bridge_module, tmp_path): assert captured["cmd"] == ["gws", "gmail", "+triage"] -def test_api_calendar_list_uses_agenda_by_default(api_module): - """calendar list without dates uses +agenda helper.""" +def test_api_calendar_list_uses_events_list(api_module): + """calendar_list calls _run_gws with events list + params.""" captured = {} def capture_run(cmd, **kwargs): captured["cmd"] = cmd - return MagicMock(returncode=0) + return MagicMock(returncode=0, stdout="{}", stderr="") args = api_module.argparse.Namespace( start="", end="", max=25, calendar="primary", func=api_module.calendar_list, ) - with patch.object(subprocess, "run", side_effect=capture_run): - with pytest.raises(SystemExit): - api_module.calendar_list(args) + with patch.object(api_module.subprocess, "run", side_effect=capture_run): + api_module.calendar_list(args) - gws_args = captured["cmd"][2:] # skip python + bridge path - assert "calendar" in gws_args - assert "+agenda" in gws_args - assert "--days" in gws_args + cmd = captured["cmd"] + # _gws_binary() returns "/usr/bin/gws", so cmd[0] is that binary + assert cmd[0] == "/usr/bin/gws" + assert "calendar" in cmd + assert "events" in cmd + assert "list" in cmd + assert "--params" in cmd + params = json.loads(cmd[cmd.index("--params") + 1]) + assert "timeMin" in params + assert "timeMax" in params + assert params["calendarId"] == "primary" def test_api_calendar_list_respects_date_range(api_module): - """calendar list with --start/--end uses raw events list API.""" + """calendar list with --start/--end passes correct time bounds.""" captured = {} def capture_run(cmd, **kwargs): captured["cmd"] = cmd - return MagicMock(returncode=0) + return MagicMock(returncode=0, stdout="{}", stderr="") args = api_module.argparse.Namespace( start="2026-04-01T00:00:00Z", @@ -162,14 +174,11 @@ def test_api_calendar_list_respects_date_range(api_module): func=api_module.calendar_list, ) - with patch.object(subprocess, "run", side_effect=capture_run): - with pytest.raises(SystemExit): - api_module.calendar_list(args) + with patch.object(api_module.subprocess, "run", side_effect=capture_run): + api_module.calendar_list(args) - gws_args = captured["cmd"][2:] - assert "events" in gws_args - assert "list" in gws_args - params_idx = gws_args.index("--params") - params = json.loads(gws_args[params_idx + 1]) + cmd = captured["cmd"] + params_idx = cmd.index("--params") + params = json.loads(cmd[params_idx + 1]) assert params["timeMin"] == "2026-04-01T00:00:00Z" assert params["timeMax"] == "2026-04-07T23:59:59Z" From c5acc6edb612d0e953d19831b5a22526baeeb8ab Mon Sep 17 00:00:00 2001 From: leeyang1990 Date: Wed, 15 Apr 2026 20:03:48 -0700 Subject: [PATCH 274/849] feat(telegram): add dedicated TELEGRAM_PROXY env var and config.yaml proxy_url support Pass platform_env_var="TELEGRAM_PROXY" to resolve_proxy_url() in both telegram.py (main connect) and telegram_network.py (fallback transport), so a Telegram-specific proxy takes priority over the generic HTTPS_PROXY. Also bridge telegram.proxy_url from config.yaml to the TELEGRAM_PROXY env var (env var takes precedence if both are set), add OPTIONAL_ENV_VARS entry, docs, and tests. Composite salvage of four community PRs: - Core approach (both call sites): #9414 by @leeyang1990 - config.yaml bridging + docs: #6530 by @WhiteWorld - Naming convention: #9074 by @brantzh6 - Earlier proxy work: #7786 by @ten-ltw Closes #9414, closes #9074, closes #7786, closes #6530 Co-authored-by: WhiteWorld Co-authored-by: brantzh6 Co-authored-by: ten-ltw --- gateway/config.py | 2 ++ gateway/platforms/telegram.py | 2 +- gateway/platforms/telegram_network.py | 2 +- hermes_cli/config.py | 6 ++++ tests/gateway/test_config.py | 36 +++++++++++++++++++ .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/messaging/telegram.md | 21 +++++++++++ 7 files changed, 68 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 0f8afc22a..5efd36729 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -638,6 +638,8 @@ def load_gateway_config() -> GatewayConfig: os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"): os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() + if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"): + os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip() if "disable_link_previews" in telegram_cfg: plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) if not isinstance(plat_data, dict): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 54e79b395..19eb72e2e 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -575,7 +575,7 @@ class TelegramAdapter(BasePlatformAdapter): "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), } - proxy_url = resolve_proxy_url() + proxy_url = resolve_proxy_url("TELEGRAM_PROXY") disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on")) fallback_ips = self._fallback_ips() if not fallback_ips: diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index 4fca934ef..ed2d60d79 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -46,7 +46,7 @@ _SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"] def _resolve_proxy_url() -> str | None: # Delegate to shared implementation (env vars + macOS system proxy detection) from gateway.platforms.base import resolve_proxy_url - return resolve_proxy_url() + return resolve_proxy_url("TELEGRAM_PROXY") class TelegramFallbackTransport(httpx.AsyncBaseTransport): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ee66d51a7..6a646d0df 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1252,6 +1252,12 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "messaging", }, + "TELEGRAM_PROXY": { + "description": "Proxy URL for Telegram connections (overrides HTTPS_PROXY). Supports http://, https://, socks5://", + "prompt": "Telegram proxy URL (optional)", + "password": False, + "category": "messaging", + }, "DISCORD_BOT_TOKEN": { "description": "Discord bot token from Developer Portal", "prompt": "Discord bot token", diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 1b5a2c530..e60bf1e92 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -300,6 +300,42 @@ class TestLoadGatewayConfig: assert config.platforms[Platform.TELEGRAM].extra["disable_link_previews"] is True + def test_bridges_telegram_proxy_url_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " proxy_url: socks5://127.0.0.1:1080\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_PROXY", raising=False) + + load_gateway_config() + + import os + assert os.environ.get("TELEGRAM_PROXY") == "socks5://127.0.0.1:1080" + + def test_telegram_proxy_env_takes_precedence_over_config(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " proxy_url: http://from-config:8080\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_PROXY", "socks5://from-env:1080") + + load_gateway_config() + + import os + assert os.environ.get("TELEGRAM_PROXY") == "socks5://from-env:1080" + class TestHomeChannelEnvOverrides: """Home channel env vars should apply even when the platform was already diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index bf6022bd8..aa0acd8c7 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -170,6 +170,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram | | `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) | | `TELEGRAM_IGNORED_THREADS` | Comma-separated Telegram forum topic/thread IDs where the bot never responds | +| `TELEGRAM_PROXY` | Proxy URL for Telegram connections — overrides `HTTPS_PROXY`. Supports `http://`, `https://`, `socks5://` | | `DISCORD_BOT_TOKEN` | Discord bot token | | `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot | | `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 4292ae4f6..0fa2e830b 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -172,6 +172,27 @@ fly deploy The gateway log should show: `[telegram] Connected to Telegram (webhook mode)`. +## Proxy Support + +If Telegram's API is blocked or you need to route traffic through a proxy, set a Telegram-specific proxy URL. This takes priority over the generic `HTTPS_PROXY` / `HTTP_PROXY` env vars. + +**Option 1: config.yaml (recommended)** + +```yaml +telegram: + proxy_url: "socks5://127.0.0.1:1080" +``` + +**Option 2: environment variable** + +```bash +TELEGRAM_PROXY=socks5://127.0.0.1:1080 +``` + +Supported schemes: `http://`, `https://`, `socks5://`. + +The proxy applies to both the main Telegram connection and the fallback IP transport. If no Telegram-specific proxy is set, the gateway falls back to `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (or macOS system proxy auto-detection). + ## Home Channel Use the `/sethome` command in any Telegram chat (DM or group) to designate it as the **home channel**. Scheduled tasks (cron jobs) deliver their results to this channel. From 8a246910bf0fc10ff2922f633715707a596af320 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:22:07 -0700 Subject: [PATCH 275/849] fix: reject startup when no provider configured instead of silent OpenRouter fallback (#10766) When no provider was set in config.yaml and auto-detection found no credentials, the agent silently fell back to bare OPENROUTER_API_KEY from the environment and sent the configured model name to OpenRouter. This produced undefined behavior -- wrong provider, wrong model routing, and auxiliary tasks (compression, vision) hitting the wrong endpoint. Fix: replace the silent fallback with a hard RuntimeError telling the user to run hermes model or hermes setup. The provider must be explicitly configured -- env vars are for secrets, not config. --- run_agent.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/run_agent.py b/run_agent.py index 47473eb51..110c3137c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1021,16 +1021,12 @@ class AIAgent: f"was found. Set the {_env_hint} environment " f"variable, or switch to a different provider with `hermes model`." ) - # Final fallback: try raw OpenRouter key - client_kwargs = { - "api_key": os.getenv("OPENROUTER_API_KEY", ""), - "base_url": OPENROUTER_BASE_URL, - "default_headers": { - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-OpenRouter-Title": "Hermes Agent", - "X-OpenRouter-Categories": "productivity,cli-agent", - }, - } + # No provider configured — reject with a clear message. + raise RuntimeError( + "No LLM provider configured. Run `hermes model` to " + "select a provider, or run `hermes setup` for first-time " + "configuration." + ) self._client_kwargs = client_kwargs # stored for rebuilding after interrupt From 9b7bd4ca61685ae6c2b9205014977c68643fd9e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:22:43 -0700 Subject: [PATCH 276/849] docs: add missing pages to sidebar navigation (#10758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement register_command() on plugin context Complete the half-built plugin slash command system. The dispatch code in cli.py and gateway/run.py already called get_plugin_command_handler() but the registration side was never implemented. Changes: - Add register_command() to PluginContext — stores handler, description, and plugin name; normalizes names; rejects conflicts with built-in commands - Add _plugin_commands dict to PluginManager - Add commands_registered tracking on LoadedPlugin - Add get_plugin_command_handler() and get_plugin_commands() module-level convenience functions - Fix commands.py to use actual plugin description in Telegram bot menu (was hardcoded 'Plugin command') - Add plugin commands to SlashCommandCompleter autocomplete - Show command count in /plugins display - 12 new tests covering registration, conflict detection, normalization, handler dispatch, and introspection Closes #10495 * docs: add register_command() to plugin guides - Build a Plugin guide: new 'Register slash commands' section with full API reference, comparison table vs register_cli_command(), sync/async examples, and conflict protection docs - Features/Plugins page: add slash commands to capabilities table and plugin types summary * docs: add missing pages to sidebar navigation - guides/aws-bedrock → Guides & Tutorials - user-guide/features/credential-pools → Integrations --- website/sidebars.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/sidebars.ts b/website/sidebars.ts index 02137fd96..77d1e6592 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -137,6 +137,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/honcho', 'user-guide/features/provider-routing', 'user-guide/features/fallback-providers', + 'user-guide/features/credential-pools', ], }, { @@ -159,6 +160,7 @@ const sidebars: SidebarsConfig = { 'guides/work-with-skills', 'guides/delegation-patterns', 'guides/migrate-from-openclaw', + 'guides/aws-bedrock', ], }, { From 36b54afbc4dfc4609943744cdcea25a012006dda Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:23:01 -0700 Subject: [PATCH 277/849] feat(plugins): add dispatch_tool() to PluginContext (#10763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the plugin interface so slash command handlers can dispatch tool calls through the registry with parent agent context wired up automatically. This is the public API for plugins that need to orchestrate tools like delegate_task — they call ctx.dispatch_tool() instead of reaching into framework internals. The parent agent is resolved lazily from _cli_ref when available (CLI mode) and omitted in gateway mode (tools degrade gracefully). Enables the hermes-deliver-plugin pattern where /deliver and /fanout slash commands spawn subagents via delegate_task without touching the agent conversation loop. 7 new tests covering: registry delegation, parent_agent injection from cli_ref, gateway mode (no cli_ref), uninitialized agent, explicit parent_agent override, kwargs forwarding, return value passthrough. --- hermes_cli/plugins.py | 31 ++++++++ tests/hermes_cli/test_plugins.py | 132 +++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5e8ff8e4f..2385a5c94 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -259,6 +259,37 @@ class PluginContext: } logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) + # -- tool dispatch ------------------------------------------------------- + + def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str: + """Dispatch a tool call through the registry, with parent agent context. + + This is the public interface for plugin slash commands that need to call + tools like ``delegate_task`` without reaching into framework internals. + The parent agent (if available) is resolved automatically — plugins never + need to access the agent directly. + + Args: + tool_name: Registry name of the tool (e.g. ``"delegate_task"``). + args: Tool arguments dict (same as what the model would pass). + **kwargs: Extra keyword args forwarded to the registry dispatch. + + Returns: + JSON string from the tool handler (same format as model tool calls). + """ + from tools.registry import registry + + # Wire up parent agent context when available (CLI mode). + # In gateway mode _cli_ref is None — tools degrade gracefully + # (workspace hints fall back to TERMINAL_CWD, no spinner). + if "parent_agent" not in kwargs: + cli = self._manager._cli_ref + agent = getattr(cli, "agent", None) if cli else None + if agent is not None: + kwargs["parent_agent"] = agent + + return registry.dispatch(tool_name, args, **kwargs) + # -- context engine registration ----------------------------------------- def register_context_engine(self, engine) -> None: diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index acc63e906..3e43acd7b 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -764,3 +764,135 @@ class TestPluginCommands: assert "cmd-b" in mgr._plugin_commands assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a" assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b" + + +# ── TestPluginDispatchTool ──────────────────────────────────────────────── + + +class TestPluginDispatchTool: + """Tests for PluginContext.dispatch_tool() — tool dispatch with agent context.""" + + def test_dispatch_tool_calls_registry(self): + """dispatch_tool() delegates to registry.dispatch().""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"result": "ok"}' + + with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"): + with patch.dict("sys.modules", {}): + with patch("tools.registry.registry", mock_registry): + result = ctx.dispatch_tool("web_search", {"query": "test"}) + + assert result == '{"result": "ok"}' + + def test_dispatch_tool_injects_parent_agent_from_cli_ref(self): + """When _cli_ref has an agent, it's passed as parent_agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_agent = MagicMock() + mock_cli = MagicMock() + mock_cli.agent = mock_agent + mgr._cli_ref = mock_cli + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + mock_registry.dispatch.assert_called_once() + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1].get("parent_agent") is mock_agent + + def test_dispatch_tool_no_parent_agent_when_no_cli_ref(self): + """When _cli_ref is None (gateway mode), no parent_agent is injected.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + call_kwargs = mock_registry.dispatch.call_args + assert "parent_agent" not in call_kwargs[1] + + def test_dispatch_tool_no_parent_agent_when_agent_is_none(self): + """When cli_ref exists but agent is None (not yet initialized), skip parent_agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_cli = MagicMock() + mock_cli.agent = None + mgr._cli_ref = mock_cli + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + call_kwargs = mock_registry.dispatch.call_args + assert "parent_agent" not in call_kwargs[1] + + def test_dispatch_tool_respects_explicit_parent_agent(self): + """Explicit parent_agent kwarg is not overwritten by _cli_ref.agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + cli_agent = MagicMock(name="cli_agent") + mock_cli = MagicMock() + mock_cli.agent = cli_agent + mgr._cli_ref = mock_cli + + explicit_agent = MagicMock(name="explicit_agent") + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent) + + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1]["parent_agent"] is explicit_agent + + def test_dispatch_tool_forwards_extra_kwargs(self): + """Extra kwargs are forwarded to registry.dispatch().""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("some_tool", {"x": 1}, task_id="test-123") + + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1]["task_id"] == "test-123" + + def test_dispatch_tool_returns_json_string(self): + """dispatch_tool() returns the raw JSON string from the registry.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"error": "Unknown tool: fake"}' + + with patch("tools.registry.registry", mock_registry): + result = ctx.dispatch_tool("fake", {}) + + assert '"error"' in result From 3ff18ffe1408b37baa1d604dadecd20fe455c55e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:33:48 -0700 Subject: [PATCH 278/849] fix: add circuit breaker to MCP tool handler to prevent retry burn loops (#10447) (#10776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP server returns errors consistently (crashed, disconnected, auth expired), the model sees each error and retries the tool call. With no circuit breaker, this burned through all 90 iterations — each one a full LLM API call plus failed MCP call — producing 15-45 minutes of zero useful output while the gateway inactivity timeout never fired (because the agent WAS active, just uselessly). Fix: track consecutive error counts per MCP server. After 3 consecutive failures (connection errors, MCP-level errors, or transport exceptions), the handler short-circuits with a message telling the model to stop retrying and use alternative approaches. The counter resets to 0 on any successful call. Closes #10447 --- tools/mcp_tool.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 5f4505224..a73aa4381 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1166,6 +1166,14 @@ class MCPServerTask: _servers: Dict[str, MCPServerTask] = {} +# Circuit breaker: consecutive error counts per server. After +# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns +# a "server unreachable" message that tells the model to stop retrying, +# preventing the 90-iteration burn loop described in #10447. +# Reset to 0 on any successful call. +_server_error_counts: Dict[str, int] = {} +_CIRCUIT_BREAKER_THRESHOLD = 3 + # Dedicated event loop running in a background daemon thread. _mcp_loop: Optional[asyncio.AbstractEventLoop] = None _mcp_thread: Optional[threading.Thread] = None @@ -1356,9 +1364,23 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): """ def _handler(args: dict, **kwargs) -> str: + # Circuit breaker: if this server has failed too many times + # consecutively, short-circuit with a clear message so the model + # stops retrying and uses alternative approaches (#10447). + if _server_error_counts.get(server_name, 0) >= _CIRCUIT_BREAKER_THRESHOLD: + return json.dumps({ + "error": ( + f"MCP server '{server_name}' is unreachable after " + f"{_CIRCUIT_BREAKER_THRESHOLD} consecutive failures. " + f"Do NOT retry this tool — use alternative approaches " + f"or ask the user to check the MCP server." + ) + }, ensure_ascii=False) + with _lock: server = _servers.get(server_name) if not server or not server.session: + _server_error_counts[server_name] = _server_error_counts.get(server_name, 0) + 1 return json.dumps({ "error": f"MCP server '{server_name}' is not connected" }, ensure_ascii=False) @@ -1399,10 +1421,21 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): return json.dumps({"result": text_result}, ensure_ascii=False) try: - return _run_on_mcp_loop(_call(), timeout=tool_timeout) + result = _run_on_mcp_loop(_call(), timeout=tool_timeout) + # Check if the MCP tool itself returned an error + try: + parsed = json.loads(result) + if "error" in parsed: + _server_error_counts[server_name] = _server_error_counts.get(server_name, 0) + 1 + else: + _server_error_counts[server_name] = 0 # success — reset + except (json.JSONDecodeError, TypeError): + _server_error_counts[server_name] = 0 # non-JSON = success + return result except InterruptedError: return _interrupted_call_result() except Exception as exc: + _server_error_counts[server_name] = _server_error_counts.get(server_name, 0) + 1 logger.error( "MCP tool %s/%s call failed: %s", server_name, tool_name, exc, From 0cf7d570e2be48e125d101c6a41aca837bb0b91c Mon Sep 17 00:00:00 2001 From: Markus Corazzione Date: Wed, 15 Apr 2026 22:25:54 -0700 Subject: [PATCH 279/849] fix(telegram): restore typing indicator and thread routing for forum General topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Telegram forum-enabled groups, the General topic does not include message_thread_id in incoming messages (it is None). This caused: 1. Messages in General losing thread context — replies went to wrong place 2. Typing indicator failing because thread_id=1 was rejected by Telegram Fix: synthesize thread_id="1" for forum groups when message_thread_id is None, then handle it correctly per operation: - send: omit message_thread_id (Telegram rejects thread_id=1 for sends) - typing: pass thread_id=1, retry without it on "thread not found" Also centralizes thread_id extraction into _metadata_thread_id() across all send methods (send, send_voice, send_image, send_document, send_video, send_animation, send_photo), replacing ~10 duplicate patterns. Salvaged from PR #7892 by @corazzione. Closes #7877, closes #7519. --- gateway/platforms/telegram.py | 97 +++++++++++++------ .../gateway/test_telegram_thread_fallback.py | 97 +++++++++++++++++++ 2 files changed, 164 insertions(+), 30 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 19eb72e2e..1bda152f5 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -134,6 +134,7 @@ class TelegramAdapter(BasePlatformAdapter): # When a chunk is near this limit, a continuation is almost certain. _SPLIT_THRESHOLD = 4000 MEDIA_GROUP_WAIT_SECONDS = 0.8 + _GENERAL_TOPIC_THREAD_ID = "1" def __init__(self, config: PlatformConfig): super().__init__(config, Platform.TELEGRAM) @@ -178,6 +179,29 @@ class TelegramAdapter(BasePlatformAdapter): allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} return "*" in allowed_ids or user_id in allowed_ids + @classmethod + def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]: + if not metadata: + return None + thread_id = metadata.get("thread_id") or metadata.get("message_thread_id") + return str(thread_id) if thread_id is not None else None + + @classmethod + def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]: + if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID: + return None + return int(thread_id) + + @classmethod + def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]: + if not thread_id: + return None + return int(thread_id) + + @staticmethod + def _is_thread_not_found_error(error: Exception) -> bool: + return "thread not found" in str(error).lower() + def _fallback_ips(self) -> list[str]: """Return validated fallback IPs from config (populated by _apply_env_overrides).""" configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else [] @@ -849,7 +873,7 @@ class TelegramAdapter(BasePlatformAdapter): ] message_ids = [] - thread_id = metadata.get("thread_id") if metadata else None + thread_id = self._metadata_thread_id(metadata) try: from telegram.error import NetworkError as _NetErr @@ -869,7 +893,7 @@ class TelegramAdapter(BasePlatformAdapter): for i, chunk in enumerate(chunks): should_thread = self._should_thread_reply(reply_to, i) reply_to_id = int(reply_to) if should_thread else None - effective_thread_id = int(thread_id) if thread_id else None + effective_thread_id = self._message_thread_id_for_send(thread_id) msg = None for _send_attempt in range(3): @@ -906,8 +930,7 @@ class TelegramAdapter(BasePlatformAdapter): # (not transient network issues). Detect and handle # specific cases instead of blindly retrying. if _BadReq and isinstance(send_err, _BadReq): - err_lower = str(send_err).lower() - if "thread not found" in err_lower and effective_thread_id is not None: + if self._is_thread_not_found_error(send_err) and effective_thread_id is not None: # Thread doesn't exist — retry without # message_thread_id so the message still # reaches the chat. @@ -917,6 +940,7 @@ class TelegramAdapter(BasePlatformAdapter): ) effective_thread_id = None continue + err_lower = str(send_err).lower() if "message to be replied not found" in err_lower and reply_to_id is not None: # Original message was deleted before we # could reply — clear reply target and retry @@ -1115,9 +1139,7 @@ class TelegramAdapter(BasePlatformAdapter): ) # Resolve thread context for thread replies - thread_id = None - if metadata: - thread_id = metadata.get("thread_id") or metadata.get("message_thread_id") + thread_id = self._metadata_thread_id(metadata) # We'll use the message_id as part of callback_data to look up session_key # Send a placeholder first, then update — or use a counter. @@ -1145,8 +1167,9 @@ class TelegramAdapter(BasePlatformAdapter): "reply_markup": keyboard, **self._link_preview_kwargs(), } - if thread_id: - kwargs["message_thread_id"] = int(thread_id) + message_thread_id = self._message_thread_id_for_send(thread_id) + if message_thread_id is not None: + kwargs["message_thread_id"] = message_thread_id msg = await self._bot.send_message(**kwargs) @@ -1579,23 +1602,23 @@ class TelegramAdapter(BasePlatformAdapter): with open(audio_path, "rb") as audio_file: # .ogg files -> send as voice (round playable bubble) if audio_path.endswith((".ogg", ".opus")): - _voice_thread = metadata.get("thread_id") if metadata else None + _voice_thread = self._metadata_thread_id(metadata) msg = await self._bot.send_voice( chat_id=int(chat_id), voice=audio_file, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_voice_thread) if _voice_thread else None, + message_thread_id=self._message_thread_id_for_send(_voice_thread), ) else: # .mp3 and others -> send as audio file - _audio_thread = metadata.get("thread_id") if metadata else None + _audio_thread = self._metadata_thread_id(metadata) msg = await self._bot.send_audio( chat_id=int(chat_id), audio=audio_file, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_audio_thread) if _audio_thread else None, + message_thread_id=self._message_thread_id_for_send(_audio_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1625,14 +1648,14 @@ class TelegramAdapter(BasePlatformAdapter): if not os.path.exists(image_path): return SendResult(success=False, error=f"Image file not found: {image_path}") - _thread = metadata.get("thread_id") if metadata else None + _thread = self._metadata_thread_id(metadata) with open(image_path, "rb") as image_file: msg = await self._bot.send_photo( chat_id=int(chat_id), photo=image_file, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_thread) if _thread else None, + message_thread_id=self._message_thread_id_for_send(_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1663,7 +1686,7 @@ class TelegramAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"File not found: {file_path}") display_name = file_name or os.path.basename(file_path) - _thread = metadata.get("thread_id") if metadata else None + _thread = self._metadata_thread_id(metadata) with open(file_path, "rb") as f: msg = await self._bot.send_document( @@ -1672,7 +1695,7 @@ class TelegramAdapter(BasePlatformAdapter): filename=display_name, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_thread) if _thread else None, + message_thread_id=self._message_thread_id_for_send(_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1696,14 +1719,14 @@ class TelegramAdapter(BasePlatformAdapter): if not os.path.exists(video_path): return SendResult(success=False, error=f"Video file not found: {video_path}") - _thread = metadata.get("thread_id") if metadata else None + _thread = self._metadata_thread_id(metadata) with open(video_path, "rb") as f: msg = await self._bot.send_video( chat_id=int(chat_id), video=f, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_thread) if _thread else None, + message_thread_id=self._message_thread_id_for_send(_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1733,13 +1756,13 @@ class TelegramAdapter(BasePlatformAdapter): try: # Telegram can send photos directly from URLs (up to ~5MB) - _photo_thread = metadata.get("thread_id") if metadata else None + _photo_thread = self._metadata_thread_id(metadata) msg = await self._bot.send_photo( chat_id=int(chat_id), photo=image_url, caption=caption[:1024] if caption else None, # Telegram caption limit reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_photo_thread) if _photo_thread else None, + message_thread_id=self._message_thread_id_for_send(_photo_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1762,6 +1785,7 @@ class TelegramAdapter(BasePlatformAdapter): photo=image_data, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=self._message_thread_id_for_send(_photo_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e2: @@ -1787,13 +1811,13 @@ class TelegramAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") try: - _anim_thread = metadata.get("thread_id") if metadata else None + _anim_thread = self._metadata_thread_id(metadata) msg = await self._bot.send_animation( chat_id=int(chat_id), animation=animation_url, caption=caption[:1024] if caption else None, reply_to_message_id=int(reply_to) if reply_to else None, - message_thread_id=int(_anim_thread) if _anim_thread else None, + message_thread_id=self._message_thread_id_for_send(_anim_thread), ) return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1810,12 +1834,23 @@ class TelegramAdapter(BasePlatformAdapter): """Send typing indicator.""" if self._bot: try: - _typing_thread = metadata.get("thread_id") if metadata else None - await self._bot.send_chat_action( - chat_id=int(chat_id), - action="typing", - message_thread_id=int(_typing_thread) if _typing_thread else None, - ) + _typing_thread = self._metadata_thread_id(metadata) + message_thread_id = self._message_thread_id_for_typing(_typing_thread) + try: + await self._bot.send_chat_action( + chat_id=int(chat_id), + action="typing", + message_thread_id=message_thread_id, + ) + except Exception as e: + if message_thread_id is not None and self._is_thread_not_found_error(e): + await self._bot.send_chat_action( + chat_id=int(chat_id), + action="typing", + message_thread_id=None, + ) + else: + raise except Exception as e: # Typing failures are non-fatal; log at debug level only. logger.debug( @@ -2760,7 +2795,9 @@ class TelegramAdapter(BasePlatformAdapter): # Resolve DM topic name and skill binding thread_id_raw = message.message_thread_id - thread_id_str = str(thread_id_raw) if thread_id_raw else None + thread_id_str = str(thread_id_raw) if thread_id_raw is not None else None + if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False): + thread_id_str = self._GENERAL_TOPIC_THREAD_ID chat_topic = None topic_skill = None diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index fee1dcc80..4930467bf 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -45,6 +45,11 @@ class FakeRetryAfter(Exception): # Build a fake telegram module tree so the adapter's internal imports work _fake_telegram = types.ModuleType("telegram") +_fake_telegram.Update = object +_fake_telegram.Bot = object +_fake_telegram.Message = object +_fake_telegram.InlineKeyboardButton = object +_fake_telegram.InlineKeyboardMarkup = object _fake_telegram_error = types.ModuleType("telegram.error") _fake_telegram_error.NetworkError = FakeNetworkError _fake_telegram_error.BadRequest = FakeBadRequest @@ -52,7 +57,21 @@ _fake_telegram_error.TimedOut = FakeTimedOut _fake_telegram.error = _fake_telegram_error _fake_telegram_constants = types.ModuleType("telegram.constants") _fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2") +_fake_telegram_constants.ChatType = SimpleNamespace( + GROUP="group", + SUPERGROUP="supergroup", + CHANNEL="channel", +) _fake_telegram.constants = _fake_telegram_constants +_fake_telegram_ext = types.ModuleType("telegram.ext") +_fake_telegram_ext.Application = object +_fake_telegram_ext.CommandHandler = object +_fake_telegram_ext.CallbackQueryHandler = object +_fake_telegram_ext.MessageHandler = object +_fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object) +_fake_telegram_ext.filters = object +_fake_telegram_request = types.ModuleType("telegram.request") +_fake_telegram_request.HTTPXRequest = object @pytest.fixture(autouse=True) @@ -61,6 +80,8 @@ def _inject_fake_telegram(monkeypatch): monkeypatch.setitem(sys.modules, "telegram", _fake_telegram) monkeypatch.setitem(sys.modules, "telegram.error", _fake_telegram_error) monkeypatch.setitem(sys.modules, "telegram.constants", _fake_telegram_constants) + monkeypatch.setitem(sys.modules, "telegram.ext", _fake_telegram_ext) + monkeypatch.setitem(sys.modules, "telegram.request", _fake_telegram_request) def _make_adapter(): @@ -68,6 +89,7 @@ def _make_adapter(): config = PlatformConfig(enabled=True, token="fake-token") adapter = object.__new__(TelegramAdapter) + adapter.config = config adapter._config = config adapter._platform = Platform.TELEGRAM adapter._connected = True @@ -82,6 +104,81 @@ def _make_adapter(): return adapter +def test_forum_general_topic_without_message_thread_id_keeps_thread_context(): + """Forum General-topic messages should keep synthetic thread context.""" + from gateway.platforms import telegram as telegram_mod + + adapter = _make_adapter() + message = SimpleNamespace( + text="hello from General", + caption=None, + chat=SimpleNamespace( + id=-100123, + type=telegram_mod.ChatType.SUPERGROUP, + is_forum=True, + title="Forum group", + ), + from_user=SimpleNamespace(id=456, full_name="Alice"), + message_thread_id=None, + reply_to_message=None, + message_id=10, + date=None, + ) + + event = adapter._build_message_event(message, msg_type=SimpleNamespace(value="text")) + + assert event.source.chat_id == "-100123" + assert event.source.chat_type == "group" + assert event.source.thread_id == "1" + + +@pytest.mark.asyncio +async def test_send_omits_general_topic_thread_id(): + """Telegram sends to forum General should omit message_thread_id=1.""" + adapter = _make_adapter() + call_log = [] + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + return SimpleNamespace(message_id=42) + + adapter._bot = SimpleNamespace(send_message=mock_send_message) + + result = await adapter.send( + chat_id="-100123", + content="test message", + metadata={"thread_id": "1"}, + ) + + assert result.success is True + assert len(call_log) == 1 + assert call_log[0]["chat_id"] == -100123 + assert call_log[0]["text"] == "test message" + assert call_log[0]["reply_to_message_id"] is None + assert call_log[0]["message_thread_id"] is None + + +@pytest.mark.asyncio +async def test_send_typing_retries_without_general_thread_when_not_found(): + """Typing for forum General should fall back if Telegram rejects thread 1.""" + adapter = _make_adapter() + call_log = [] + + async def mock_send_chat_action(**kwargs): + call_log.append(dict(kwargs)) + if kwargs.get("message_thread_id") == 1: + raise FakeBadRequest("Message thread not found") + + adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action) + + await adapter.send_typing("-100123", metadata={"thread_id": "1"}) + + assert call_log == [ + {"chat_id": -100123, "action": "typing", "message_thread_id": 1}, + {"chat_id": -100123, "action": "typing", "message_thread_id": None}, + ] + + @pytest.mark.asyncio async def test_send_retries_without_thread_on_thread_not_found(): """When message_thread_id causes 'thread not found', retry without it.""" From 8e06db56fd6677d02d70d6e6db476a99aff7e6d5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 01:04:35 -0500 Subject: [PATCH 280/849] 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 000000000..49ea56936 --- /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 b7f895539..6a48bc1be 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 ee1a70978..4968d74c2 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 b20804380..1a23943c0 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 719116cb8..aa7e28d4d 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 cb980248a..7cb893c4c 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 4627244e3..9b8a277be 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 5fa817f5a..d4c1e404f 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 000000000..0d8386fbd --- /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 221a7e5ae..e862045cf 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 000000000..63583fb70 --- /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 000000000..1abc4bdde --- /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 2f40b33c9..6eff78c58 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 8bfa7fe20..c2360dd0c 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 4093982f19578008a5aefd8f4c1dac971ec9286d Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:45:24 +0530 Subject: [PATCH 281/849] fix: recompute Copilot api_mode after model switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recomputes GitHub Copilot api_mode from the selected model in the shared /model switch path. Before this change, Copilot could carry a stale codex_responses mode forward from a GPT-5 selection into a later Claude model switch, causing unsupported_api_for_model errors. Cherry-picked from #10533 by @helix4u with: - Comment specificity (Provider-specific → Copilot api_mode override) - Fix pre-existing duplicate opencode-go in set literal - Extract test mock helper to reduce duplication - Add GPT-5 → GPT-5 regression test (keeps codex_responses) --- hermes_cli/model_switch.py | 9 +- .../test_model_switch_copilot_api_mode.py | 101 ++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_model_switch_copilot_api_mode.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 11c2fa06a..5a494dc19 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -457,6 +457,7 @@ def switch_model( ModelSwitchResult with all information the caller needs. """ from hermes_cli.models import ( + copilot_model_api_mode, detect_provider_for_model, validate_requested_model, opencode_model_api_mode, @@ -714,8 +715,12 @@ def switch_model( if validation.get("corrected_model"): new_model = validation["corrected_model"] + # --- Copilot api_mode override --- + if target_provider in {"copilot", "github-copilot"}: + api_mode = copilot_model_api_mode(new_model, api_key=api_key) + # --- OpenCode api_mode override --- - if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}: + if target_provider in {"opencode-zen", "opencode-go", "opencode"}: api_mode = opencode_model_api_mode(target_provider, new_model) # --- Determine api_mode if not already set --- @@ -1098,5 +1103,3 @@ def list_authenticated_providers( results.sort(key=lambda r: (not r["is_current"], -r["total_models"])) return results - - diff --git a/tests/hermes_cli/test_model_switch_copilot_api_mode.py b/tests/hermes_cli/test_model_switch_copilot_api_mode.py new file mode 100644 index 000000000..0248d827a --- /dev/null +++ b/tests/hermes_cli/test_model_switch_copilot_api_mode.py @@ -0,0 +1,101 @@ +"""Regression tests for Copilot api_mode recomputation during /model switch. + +When switching models within the Copilot provider (e.g. GPT-5 → Claude), +the stale api_mode from resolve_runtime_provider must be overridden with +a fresh value computed from the *new* model. Without the fix, Claude +requests went through the Responses API and failed with +``unsupported_api_for_model``. +""" + +from unittest.mock import patch + +from hermes_cli.model_switch import switch_model + + +_MOCK_VALIDATION = { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, +} + + +def _run_copilot_switch( + raw_input: str, + current_provider: str = "copilot", + current_model: str = "gpt-5.4", + explicit_provider: str = "", + runtime_api_mode: str = "codex_responses", +): + """Run switch_model with Copilot mocks and return the result.""" + with ( + patch("hermes_cli.model_switch.resolve_alias", return_value=None), + patch("hermes_cli.model_switch.list_provider_models", return_value=[]), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "ghu_test_token", + "base_url": "https://api.githubcopilot.com", + "api_mode": runtime_api_mode, + }, + ), + patch( + "hermes_cli.models.validate_requested_model", + return_value=_MOCK_VALIDATION, + ), + patch("hermes_cli.model_switch.get_model_info", return_value=None), + patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), + patch("hermes_cli.models.detect_provider_for_model", return_value=None), + ): + return switch_model( + raw_input=raw_input, + current_provider=current_provider, + current_model=current_model, + explicit_provider=explicit_provider, + ) + + +def test_same_provider_copilot_switch_recomputes_api_mode(): + """GPT-5 → Claude on copilot: api_mode must flip to chat_completions.""" + result = _run_copilot_switch( + raw_input="claude-opus-4.6", + current_provider="copilot", + current_model="gpt-5.4", + ) + + assert result.success, f"switch_model failed: {result.error_message}" + assert result.new_model == "claude-opus-4.6" + assert result.target_provider == "copilot" + assert result.api_mode == "chat_completions" + + +def test_explicit_copilot_switch_uses_selected_model_api_mode(): + """Cross-provider switch to copilot: api_mode from new model, not stale runtime.""" + result = _run_copilot_switch( + raw_input="claude-opus-4.6", + current_provider="openrouter", + current_model="anthropic/claude-sonnet-4.6", + explicit_provider="copilot", + ) + + assert result.success, f"switch_model failed: {result.error_message}" + assert result.new_model == "claude-opus-4.6" + assert result.target_provider == "github-copilot" + assert result.api_mode == "chat_completions" + + +def test_copilot_gpt5_keeps_codex_responses(): + """GPT-5 → GPT-5 on copilot: api_mode must stay codex_responses.""" + result = _run_copilot_switch( + raw_input="gpt-5.4-mini", + current_provider="copilot", + current_model="gpt-5.4", + runtime_api_mode="codex_responses", + ) + + assert result.success, f"switch_model failed: {result.error_message}" + assert result.new_model == "gpt-5.4-mini" + assert result.target_provider == "copilot" + # gpt-5.4-mini is a GPT-5 variant — should use codex_responses + # (gpt-5-mini is the special case that uses chat_completions) + assert result.api_mode == "codex_responses" From 8021a735c283b1b9a062ba6e64dae0090214482b Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:27:54 +0530 Subject: [PATCH 282/849] fix(gateway): preserve notify context in executor threads Gateway executor work now inherits the active session contextvars via copy_context() so background process watchers retain the correct platform/chat/user/session metadata for routing completion events back to the originating chat. Cherry-picked from #10647 by @helix4u with: - Use asyncio.get_running_loop() instead of deprecated get_event_loop() - Strip trailing whitespace - Add *args forwarding test - Add exception propagation test --- gateway/run.py | 18 ++++---- tests/gateway/test_session_env.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 67ec4d420..28a350a39 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -24,6 +24,7 @@ import signal import tempfile import threading import time +from contextvars import copy_context from pathlib import Path from datetime import datetime from typing import Dict, Optional, Any, List @@ -5715,8 +5716,7 @@ class GatewayRunner: task_id=task_id, ) - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, run_sync) + result = await self._run_in_executor_with_context(run_sync) response = result.get("final_response", "") if result else "" if not response and result and result.get("error"): @@ -5898,8 +5898,7 @@ class GatewayRunner: task_id=task_id, ) - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, run_sync) + result = await self._run_in_executor_with_context(run_sync) response = (result.get("final_response") or "") if result else "" if not response and result and result.get("error"): @@ -7318,7 +7317,13 @@ class GatewayRunner: """Restore session context variables to their pre-handler values.""" from gateway.session_context import clear_session_vars clear_session_vars(tokens) - + + async def _run_in_executor_with_context(self, func, *args): + """Run blocking work in the thread pool while preserving session contextvars.""" + loop = asyncio.get_running_loop() + ctx = copy_context() + return await loop.run_in_executor(None, ctx.run, func, *args) + async def _enrich_message_with_vision( self, user_text: str, @@ -9094,9 +9099,8 @@ class GatewayRunner: _agent_warning_raw = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900)) _agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None _warning_fired = False - loop = asyncio.get_event_loop() _executor_task = asyncio.ensure_future( - loop.run_in_executor(None, run_sync) + self._run_in_executor_with_context(run_sync) ) _inactivity_timeout = False diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index 85899e2fd..c4765c144 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -251,3 +251,72 @@ def test_session_key_no_race_condition_with_contextvars(monkeypatch): assert results["session-B"] == "session-B", ( f"Session B got '{results['session-B']}' instead of 'session-B' — race condition!" ) + + +@pytest.mark.asyncio +async def test_run_in_executor_with_context_preserves_session_env(monkeypatch): + """Gateway executor work should inherit session contextvars for tool routing.""" + runner = object.__new__(GatewayRunner) + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="2144471399", + chat_type="dm", + user_id="123456", + user_name="alice", + thread_id=None, + ) + context = SessionContext( + source=source, + connected_platforms=[], + home_channels={}, + session_key="agent:main:telegram:dm:2144471399", + ) + + tokens = runner._set_session_env(context) + try: + result = await runner._run_in_executor_with_context( + lambda: { + "platform": get_session_env("HERMES_SESSION_PLATFORM"), + "chat_id": get_session_env("HERMES_SESSION_CHAT_ID"), + "user_id": get_session_env("HERMES_SESSION_USER_ID"), + "session_key": get_session_env("HERMES_SESSION_KEY"), + } + ) + finally: + runner._clear_session_env(tokens) + + assert result == { + "platform": "telegram", + "chat_id": "2144471399", + "user_id": "123456", + "session_key": "agent:main:telegram:dm:2144471399", + } + + +@pytest.mark.asyncio +async def test_run_in_executor_with_context_forwards_args(): + """_run_in_executor_with_context should forward *args to the callable.""" + runner = object.__new__(GatewayRunner) + + def add(a, b): + return a + b + + result = await runner._run_in_executor_with_context(add, 3, 7) + assert result == 10 + + +@pytest.mark.asyncio +async def test_run_in_executor_with_context_propagates_exceptions(): + """Exceptions inside the executor should propagate to the caller.""" + runner = object.__new__(GatewayRunner) + + def blow_up(): + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + await runner._run_in_executor_with_context(blow_up) From 1b61ec470b1b0b8a318a57fd7a9f5925143652e8 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:32:05 -0700 Subject: [PATCH 283/849] feat: add Ollama Cloud as built-in provider Add ollama-cloud as a first-class provider with full parity to existing API-key providers (gemini, zai, minimax, etc.): - PROVIDER_REGISTRY entry with OLLAMA_API_KEY env var - Provider aliases: ollama -> custom (local), ollama_cloud -> ollama-cloud - models.dev integration for accurate context lengths - URL-to-provider mapping (ollama.com -> ollama-cloud) - Passthrough model normalization (preserves Ollama model:tag format) - Default auxiliary model (nemotron-3-nano:30b) - HermesOverlay in providers.py - CLI --provider choices, CANONICAL_PROVIDERS entry - Dynamic model discovery with disk caching (1hr TTL) - 37 provider-specific tests Cherry-picked from PR #6038 by kshitijk4poor. Closes #3926 --- .env.example | 9 + agent/auxiliary_client.py | 1 + agent/model_metadata.py | 4 +- agent/models_dev.py | 1 + cli-config.yaml.example | 8 +- hermes_cli/auth.py | 12 +- hermes_cli/config.py | 16 + hermes_cli/main.py | 65 ++-- hermes_cli/model_normalize.py | 1 + hermes_cli/models.py | 125 +++++++ hermes_cli/providers.py | 7 +- .../hermes_cli/test_ollama_cloud_provider.py | 351 ++++++++++++++++++ 12 files changed, 563 insertions(+), 37 deletions(-) create mode 100644 tests/hermes_cli/test_ollama_cloud_provider.py diff --git a/.env.example b/.env.example index 76be6ce26..066e93f7c 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,15 @@ # Optional base URL override (default: Google's OpenAI-compatible endpoint) # GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai +# ============================================================================= +# LLM PROVIDER (Ollama Cloud) +# ============================================================================= +# Cloud-hosted open models via Ollama's OpenAI-compatible endpoint. +# Get your key at: https://ollama.com/settings +# OLLAMA_API_KEY=your_ollama_key_here +# Optional base URL override (default: https://ollama.com/v1) +# OLLAMA_BASE_URL=https://ollama.com/v1 + # ============================================================================= # LLM PROVIDER (z.ai / GLM) # ============================================================================= diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9702da941..34d7d4250 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -104,6 +104,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = { "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", "kilocode": "google/gemini-3-flash-preview", + "ollama-cloud": "nemotron-3-nano:30b", } # Vision-specific model overrides for direct providers. diff --git a/agent/model_metadata.py b/agent/model_metadata.py index a0e3bea8c..db3048941 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek", "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "qwen-oauth", "xiaomi", @@ -33,6 +33,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "google", "google-gemini", "google-ai-studio", "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", + "ollama", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", "arcee-ai", "arceeai", @@ -239,6 +240,7 @@ _URL_TO_PROVIDER: Dict[str, str] = { "api.x.ai": "xai", "api.xiaomimimo.com": "xiaomi", "xiaomimimo.com": "xiaomi", + "ollama.com": "ollama-cloud", } diff --git a/agent/models_dev.py b/agent/models_dev.py index 373daafc3..42c8925ff 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -169,6 +169,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "togetherai": "togetherai", "perplexity": "perplexity", "cohere": "cohere", + "ollama-cloud": "ollama-cloud", } # Reverse mapping: models.dev → Hermes (built lazily) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 962b554b4..8c0484abd 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -26,6 +26,7 @@ model: # "huggingface" - Hugging Face Inference (requires: HF_TOKEN) # "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY) # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) + # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY) # @@ -37,12 +38,6 @@ model: # base_url: "http://localhost:1234/v1" # No API key needed — local servers typically ignore auth. # - # For Ollama Cloud (https://ollama.com/pricing): - # provider: "custom" - # base_url: "https://ollama.com/v1" - # Set OLLAMA_API_KEY in .env — automatically picked up when base_url - # points to ollama.com. - # # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var. provider: "auto" @@ -337,6 +332,7 @@ compression: # "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY) # "nous" - Force Nous Portal (requires: hermes login) # "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY) +# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY) # "codex" - Force Codex OAuth (requires: hermes model → Codex). # Uses gpt-5.3-codex which supports vision. # "main" - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY). diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b75b6b757..966082787 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -70,6 +70,7 @@ DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1" DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" +DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @@ -274,6 +275,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=("XIAOMI_API_KEY",), base_url_env_var="XIAOMI_BASE_URL", ), + "ollama-cloud": ProviderConfig( + id="ollama-cloud", + name="Ollama Cloud", + auth_type="api_key", + inference_base_url=DEFAULT_OLLAMA_CLOUD_BASE_URL, + api_key_env_vars=("OLLAMA_API_KEY",), + base_url_env_var="OLLAMA_BASE_URL", + ), "bedrock": ProviderConfig( id="bedrock", name="AWS Bedrock", @@ -937,7 +946,8 @@ def resolve_provider( "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", # Local server aliases — route through the generic custom provider "lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom", - "ollama": "custom", "vllm": "custom", "llamacpp": "custom", + "ollama": "custom", "ollama_cloud": "ollama-cloud", + "vllm": "custom", "llamacpp": "custom", "llama.cpp": "custom", "llama-cpp": "custom", } normalized = _PROVIDER_ALIASES.get(normalized, normalized) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6a646d0df..7f639726f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1024,6 +1024,22 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "OLLAMA_API_KEY": { + "description": "Ollama Cloud API key (ollama.com — cloud-hosted open models)", + "prompt": "Ollama Cloud API key", + "url": "https://ollama.com/settings", + "password": True, + "category": "provider", + "advanced": True, + }, + "OLLAMA_BASE_URL": { + "description": "Ollama Cloud base URL override (default: https://ollama.com/v1)", + "prompt": "Ollama base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "XIAOMI_API_KEY": { "description": "Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)", "prompt": "Xiaomi MiMo API Key", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5c6db4e90..9d0615d53 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1141,7 +1141,7 @@ 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"): + 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 ────────────── @@ -2734,34 +2734,43 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): # 1. models.dev registry (cached, filtered for agentic/tool-capable models) # 2. Curated static fallback list (offline insurance) # 3. Live /models endpoint probe (small providers without models.dev data) - curated = _PROVIDER_MODELS.get(provider_id, []) - - # Try models.dev first — returns tool-capable models, filtered for noise - mdev_models: list = [] - try: - from agent.models_dev import list_agentic_models - mdev_models = list_agentic_models(provider_id) - except Exception: - pass - - if mdev_models: - model_list = mdev_models - print(f" Found {len(model_list)} model(s) from models.dev registry") - 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.") - else: + # + # 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 "") - live_models = fetch_api_models(api_key_for_probe, effective_base) - if live_models and len(live_models) >= len(curated): - model_list = live_models - print(f" Found {len(model_list)} model(s) from {pconfig.name} API") - else: + 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: + curated = _PROVIDER_MODELS.get(provider_id, []) + + # Try models.dev first — returns tool-capable models, filtered for noise + mdev_models: list = [] + try: + from agent.models_dev import list_agentic_models + mdev_models = list_agentic_models(provider_id) + except Exception: + pass + + if mdev_models: + model_list = mdev_models + print(f" Found {len(model_list)} model(s) from models.dev registry") + elif curated and len(curated) >= 8: + # Curated list is substantial — use it directly, skip live probe model_list = curated - if model_list: - 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 + 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 "") + live_models = fetch_api_models(api_key_for_probe, effective_base) + if live_models and len(live_models) >= len(curated): + model_list = live_models + print(f" Found {len(model_list)} model(s) from {pconfig.name} API") + else: + model_list = curated + if model_list: + 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] @@ -4860,7 +4869,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"], + choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"], default=None, help="Inference provider (default: auto)" ) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 40afe003b..22ab0fa3f 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -96,6 +96,7 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "qwen-oauth", "xiaomi", "arcee", + "ollama-cloud", "custom", }) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 9fc68933e..9812fc97e 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -11,7 +11,9 @@ import json import os import urllib.request import urllib.error +import time from difflib import get_close_matches +from pathlib import Path from typing import Any, NamedTuple, Optional COPILOT_BASE_URL = "https://api.githubcopilot.com" @@ -547,6 +549,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), + ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"), ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"), ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"), ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), @@ -559,6 +562,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS} _PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider + _PROVIDER_ALIASES = { "glm": "zai", "z-ai": "zai", @@ -611,6 +615,8 @@ _PROVIDER_ALIASES = { "grok": "xai", "x-ai": "xai", "x.ai": "xai", + "ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud + "ollama_cloud": "ollama-cloud", } @@ -1786,6 +1792,125 @@ def fetch_api_models( return probe_api_models(api_key, base_url, timeout=timeout).get("models") +# --------------------------------------------------------------------------- +# Ollama Cloud — merged model discovery with disk cache +# --------------------------------------------------------------------------- + + + +_OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour + + +def _ollama_cloud_cache_path() -> Path: + """Return the path for the Ollama Cloud model cache.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "ollama_cloud_models_cache.json" + + +def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: + """Load cached Ollama Cloud models from disk. + + Args: + ignore_ttl: If True, return data even if the TTL has expired (stale fallback). + """ + try: + cache_path = _ollama_cloud_cache_path() + if not cache_path.exists(): + return None + with open(cache_path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return None + models = data.get("models") + if not (isinstance(models, list) and models): + return None + if not ignore_ttl: + cached_at = data.get("cached_at", 0) + if (time.time() - cached_at) > _OLLAMA_CLOUD_CACHE_TTL: + return None # stale + return data + except Exception: + pass + return None + + +def _save_ollama_cloud_cache(models: list[str]) -> None: + """Persist the merged Ollama Cloud model list to disk.""" + try: + from utils import atomic_json_write + cache_path = _ollama_cloud_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(cache_path, {"models": models, "cached_at": time.time()}, indent=None) + except Exception: + pass + + +def fetch_ollama_cloud_models( + api_key: Optional[str] = None, + base_url: Optional[str] = None, + *, + force_refresh: bool = False, +) -> list[str]: + """Fetch Ollama Cloud models by merging live API + models.dev, with disk cache. + + Resolution order: + 1. Disk cache (if fresh, < 1 hour, and not force_refresh) + 2. Live ``/v1/models`` endpoint (primary — freshest source) + 3. models.dev registry (secondary — fills gaps for unlisted models) + 4. Merge: live models first, then models.dev additions (deduped) + + Returns a list of model IDs (never None — empty list on total failure). + """ + # 1. Check disk cache + if not force_refresh: + cached = _load_ollama_cloud_cache() + if cached is not None: + return cached["models"] + + # 2. Live API probe + if not api_key: + api_key = os.getenv("OLLAMA_API_KEY", "") + if not base_url: + base_url = os.getenv("OLLAMA_BASE_URL", "") or "https://ollama.com/v1" + + live_models: list[str] = [] + if api_key: + result = fetch_api_models(api_key, base_url, timeout=8.0) + if result: + live_models = result + + # 3. models.dev registry + mdev_models: list[str] = [] + try: + from agent.models_dev import list_agentic_models + mdev_models = list_agentic_models("ollama-cloud") + except Exception: + pass + + # 4. Merge: live first, then models.dev additions (deduped, order-preserving) + if live_models or mdev_models: + seen: set[str] = set() + merged: list[str] = [] + for m in live_models: + if m and m not in seen: + seen.add(m) + merged.append(m) + for m in mdev_models: + if m and m not in seen: + seen.add(m) + merged.append(m) + if merged: + _save_ollama_cloud_cache(merged) + return merged + + # Total failure — return stale cache if available (ignore TTL) + stale = _load_ollama_cloud_cache(ignore_ttl=True) + if stale is not None: + return stale["models"] + + return [] + + def validate_requested_model( model_name: str, provider: Optional[str], diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 8311e3652..eae832055 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -141,6 +141,10 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://api.arcee.ai/api/v1", base_url_env_var="ARCEE_BASE_URL", ), + "ollama-cloud": HermesOverlay( + transport="openai_chat", + base_url_env_var="OLLAMA_BASE_URL", + ), } @@ -250,7 +254,7 @@ ALIASES: Dict[str, str] = { "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio", - "ollama": "ollama-cloud", + "ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud "vllm": "local", "llamacpp": "local", "llama.cpp": "local", @@ -269,6 +273,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "xiaomi": "Xiaomi MiMo", "local": "Local endpoint", "bedrock": "AWS Bedrock", + "ollama-cloud": "Ollama Cloud", } diff --git a/tests/hermes_cli/test_ollama_cloud_provider.py b/tests/hermes_cli/test_ollama_cloud_provider.py new file mode 100644 index 000000000..9dad26092 --- /dev/null +++ b/tests/hermes_cli/test_ollama_cloud_provider.py @@ -0,0 +1,351 @@ +"""Tests for Ollama Cloud provider integration.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + +from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials +from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider +from hermes_cli.model_normalize import normalize_model_for_provider +from agent.model_metadata import _URL_TO_PROVIDER, _PROVIDER_PREFIXES +from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models + + +# ── Provider Registry ── + +class TestOllamaCloudProviderRegistry: + def test_ollama_cloud_in_registry(self): + assert "ollama-cloud" in PROVIDER_REGISTRY + + def test_ollama_cloud_config(self): + pconfig = PROVIDER_REGISTRY["ollama-cloud"] + assert pconfig.id == "ollama-cloud" + assert pconfig.name == "Ollama Cloud" + assert pconfig.auth_type == "api_key" + assert pconfig.inference_base_url == "https://ollama.com/v1" + + def test_ollama_cloud_env_vars(self): + pconfig = PROVIDER_REGISTRY["ollama-cloud"] + assert pconfig.api_key_env_vars == ("OLLAMA_API_KEY",) + assert pconfig.base_url_env_var == "OLLAMA_BASE_URL" + + def test_ollama_cloud_base_url(self): + assert "ollama.com" in PROVIDER_REGISTRY["ollama-cloud"].inference_base_url + + +# ── Provider Aliases ── + +PROVIDER_ENV_VARS = ( + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "OLLAMA_API_KEY", + "GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", + "MINIMAX_API_KEY", "DEEPSEEK_API_KEY", +) + +@pytest.fixture(autouse=True) +def _clean_provider_env(monkeypatch): + for var in PROVIDER_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +class TestOllamaCloudAliases: + def test_explicit_ollama_cloud(self): + assert resolve_provider("ollama-cloud") == "ollama-cloud" + + def test_alias_ollama_underscore(self): + """ollama_cloud (underscore) is the unambiguous cloud alias.""" + assert resolve_provider("ollama_cloud") == "ollama-cloud" + + def test_bare_ollama_stays_local(self): + """Bare 'ollama' alias routes to 'custom' (local) — not cloud.""" + assert resolve_provider("ollama") == "custom" + + def test_models_py_aliases(self): + assert _PROVIDER_ALIASES.get("ollama_cloud") == "ollama-cloud" + # bare "ollama" stays local + assert _PROVIDER_ALIASES.get("ollama") == "custom" + + def test_normalize_provider(self): + assert normalize_provider("ollama-cloud") == "ollama-cloud" + + +# ── Auto-detection ── + +class TestOllamaCloudAutoDetection: + def test_auto_detects_ollama_api_key(self, monkeypatch): + monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key") + assert resolve_provider("auto") == "ollama-cloud" + + +# ── Credential Resolution ── + +class TestOllamaCloudCredentials: + def test_resolve_with_ollama_api_key(self, monkeypatch): + monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret") + creds = resolve_api_key_provider_credentials("ollama-cloud") + assert creds["provider"] == "ollama-cloud" + assert creds["api_key"] == "ollama-secret" + assert creds["base_url"] == "https://ollama.com/v1" + + def test_resolve_with_custom_base_url(self, monkeypatch): + monkeypatch.setenv("OLLAMA_API_KEY", "key") + monkeypatch.setenv("OLLAMA_BASE_URL", "https://custom.ollama/v1") + creds = resolve_api_key_provider_credentials("ollama-cloud") + assert creds["base_url"] == "https://custom.ollama/v1" + + def test_runtime_ollama_cloud(self, monkeypatch): + monkeypatch.setenv("OLLAMA_API_KEY", "ollama-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="ollama-cloud") + assert result["provider"] == "ollama-cloud" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "ollama-key" + assert result["base_url"] == "https://ollama.com/v1" + + +# ── Model Catalog (dynamic — no static list) ── + +class TestOllamaCloudModelCatalog: + def test_no_static_model_list(self): + """Ollama Cloud models are fetched dynamically — no static list to maintain.""" + assert "ollama-cloud" not in _PROVIDER_MODELS + + def test_provider_label(self): + assert "ollama-cloud" in _PROVIDER_LABELS + assert _PROVIDER_LABELS["ollama-cloud"] == "Ollama Cloud" + + +# ── Merged Model Discovery ── + +class TestOllamaCloudMergedDiscovery: + def test_merges_live_and_models_dev(self, tmp_path, monkeypatch): + """Live API models appear first, models.dev additions fill gaps.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + mock_mdev = { + "ollama-cloud": { + "models": { + "glm-5": {"tool_call": True}, + "kimi-k2.5": {"tool_call": True}, + "nemotron-3-super": {"tool_call": True}, + } + } + } + with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b", "glm-5"]), \ + patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + # Live models first, then models.dev additions (deduped) + assert result[0] == "qwen3.5:397b" # from live API + assert result[1] == "glm-5" # from live API (also in models.dev) + assert "kimi-k2.5" in result # from models.dev only + assert "nemotron-3-super" in result # from models.dev only + assert result.count("glm-5") == 1 # no duplicates + + def test_falls_back_to_models_dev_without_api_key(self, tmp_path, monkeypatch): + """Without API key, only models.dev results are returned.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + "glm-5": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["glm-5"] + + def test_uses_disk_cache(self, tmp_path, monkeypatch): + """Second call returns cached results without hitting APIs.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \ + patch("agent.models_dev.fetch_models_dev", return_value={}): + first = fetch_ollama_cloud_models(force_refresh=True) + assert first == ["model-a"] + assert mock_api.call_count == 1 + + # Second call — should use disk cache, not call API + second = fetch_ollama_cloud_models() + assert second == ["model-a"] + assert mock_api.call_count == 1 # no extra API call + + def test_force_refresh_bypasses_cache(self, tmp_path, monkeypatch): + """force_refresh=True always hits the API even with fresh cache.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \ + patch("agent.models_dev.fetch_models_dev", return_value={}): + fetch_ollama_cloud_models(force_refresh=True) + fetch_ollama_cloud_models(force_refresh=True) + assert mock_api.call_count == 2 + + def test_stale_cache_used_on_total_failure(self, tmp_path, monkeypatch): + """If both API and models.dev fail, stale cache is returned.""" + from hermes_cli.models import fetch_ollama_cloud_models, _save_ollama_cloud_cache + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + # Pre-populate a stale cache + _save_ollama_cloud_cache(["stale-model"]) + + # Make the cache appear stale by backdating it + import json + cache_path = tmp_path / "ollama_cloud_models_cache.json" + with open(cache_path) as f: + data = json.load(f) + data["cached_at"] = 0 # epoch = very stale + with open(cache_path, "w") as f: + json.dump(data, f) + + with patch("hermes_cli.models.fetch_api_models", return_value=None), \ + patch("agent.models_dev.fetch_models_dev", return_value={}): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["stale-model"] + + def test_empty_on_total_failure_no_cache(self, tmp_path, monkeypatch): + """Returns empty list when everything fails and no cache exists.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + with patch("agent.models_dev.fetch_models_dev", return_value={}): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == [] + + +# ── Model Normalization ── + +class TestOllamaCloudModelNormalization: + def test_passthrough_bare_name(self): + """Ollama Cloud is a passthrough provider — model names used as-is.""" + assert normalize_model_for_provider("qwen3.5:397b", "ollama-cloud") == "qwen3.5:397b" + + def test_passthrough_with_tag(self): + assert normalize_model_for_provider("cogito-2.1:671b", "ollama-cloud") == "cogito-2.1:671b" + + def test_passthrough_no_tag(self): + assert normalize_model_for_provider("glm-5", "ollama-cloud") == "glm-5" + + +# ── URL-to-Provider Mapping ── + +class TestOllamaCloudUrlMapping: + def test_url_to_provider(self): + assert _URL_TO_PROVIDER.get("ollama.com") == "ollama-cloud" + + def test_provider_prefix_canonical(self): + assert "ollama-cloud" in _PROVIDER_PREFIXES + + def test_provider_prefix_alias(self): + assert "ollama" in _PROVIDER_PREFIXES + + +# ── models.dev Integration ── + +class TestOllamaCloudModelsDev: + def test_ollama_cloud_mapped(self): + assert PROVIDER_TO_MODELS_DEV.get("ollama-cloud") == "ollama-cloud" + + def test_list_agentic_models_with_mock_data(self): + """list_agentic_models filters correctly from mock models.dev data.""" + mock_data = { + "ollama-cloud": { + "models": { + "qwen3.5:397b": {"tool_call": True}, + "glm-5": {"tool_call": True}, + "nemotron-3-nano:30b": {"tool_call": True}, + "some-embedding:latest": {"tool_call": False}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_data): + result = list_agentic_models("ollama-cloud") + assert "qwen3.5:397b" in result + assert "glm-5" in result + assert "nemotron-3-nano:30b" in result + assert "some-embedding:latest" not in result # no tool_call + + +# ── Agent Init (no SyntaxError) ── + +class TestOllamaCloudAgentInit: + def test_agent_imports_without_error(self): + """Verify run_agent.py has no SyntaxError.""" + import importlib + import run_agent + importlib.reload(run_agent) + + def test_ollama_cloud_agent_uses_chat_completions(self, monkeypatch): + """Ollama Cloud falls through to chat_completions — no special elif needed.""" + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + with patch("run_agent.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from run_agent import AIAgent + agent = AIAgent( + model="qwen3.5:397b", + provider="ollama-cloud", + api_key="test-key", + base_url="https://ollama.com/v1", + ) + assert agent.api_mode == "chat_completions" + assert agent.provider == "ollama-cloud" + + +# ── providers.py New System ── + +class TestOllamaCloudProvidersNew: + def test_overlay_exists(self): + from hermes_cli.providers import HERMES_OVERLAYS + assert "ollama-cloud" in HERMES_OVERLAYS + overlay = HERMES_OVERLAYS["ollama-cloud"] + assert overlay.transport == "openai_chat" + assert overlay.base_url_env_var == "OLLAMA_BASE_URL" + + def test_alias_resolves(self): + from hermes_cli.providers import normalize_provider as np + assert np("ollama") == "custom" # bare "ollama" = local + assert np("ollama-cloud") == "ollama-cloud" + + def test_label_override(self): + from hermes_cli.providers import _LABEL_OVERRIDES + assert _LABEL_OVERRIDES.get("ollama-cloud") == "Ollama Cloud" + + def test_get_label(self): + from hermes_cli.providers import get_label + assert get_label("ollama-cloud") == "Ollama Cloud" + + def test_get_provider(self): + from hermes_cli.providers import get_provider + pdef = get_provider("ollama-cloud") + assert pdef is not None + assert pdef.id == "ollama-cloud" + assert pdef.transport == "openai_chat" + + +# ── Auxiliary Model ── + +class TestOllamaCloudAuxiliary: + def test_aux_model_defined(self): + from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS + assert "ollama-cloud" in _API_KEY_PROVIDER_AUX_MODELS + assert _API_KEY_PROVIDER_AUX_MODELS["ollama-cloud"] == "nemotron-3-nano:30b" From 8011aa31babb95cbb814b522abd9a5ccbbfb6b31 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Thu, 16 Apr 2026 12:31:24 +0800 Subject: [PATCH 284/849] fix(agent): continue ollama glm truncation replies --- run_agent.py | 64 ++++++++++++++++++ tests/run_agent/test_run_agent.py | 108 ++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/run_agent.py b/run_agent.py index 110c3137c..625dc5fce 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2103,6 +2103,59 @@ class AIAgent: content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) return content + @staticmethod + def _has_natural_response_ending(content: str) -> bool: + """Heuristic: does visible assistant text look intentionally finished?""" + if not content: + return False + stripped = content.rstrip() + if not stripped: + return False + if stripped.endswith("```"): + return True + return stripped[-1] in '.!?:)"\']}。!?:)】」』》' + + def _is_ollama_glm_backend(self) -> bool: + """Detect the narrow backend family affected by Ollama/GLM stop misreports.""" + model_lower = (self.model or "").lower() + provider_lower = (self.provider or "").lower() + if "glm" not in model_lower and provider_lower != "zai": + return False + if "ollama" in self._base_url_lower or ":11434" in self._base_url_lower: + return True + return bool(self.base_url and is_local_endpoint(self.base_url)) + + def _should_treat_stop_as_truncated( + self, + finish_reason: str, + assistant_message, + messages: Optional[list] = None, + ) -> bool: + """Detect conservative stop->length misreports for Ollama-hosted GLM models.""" + if finish_reason != "stop" or self.api_mode != "chat_completions": + return False + if not self._is_ollama_glm_backend(): + return False + if not any( + isinstance(msg, dict) and msg.get("role") == "tool" + for msg in (messages or []) + ): + return False + if assistant_message is None or getattr(assistant_message, "tool_calls", None): + return False + + content = getattr(assistant_message, "content", None) + if not isinstance(content, str): + return False + + visible_text = self._strip_think_blocks(content).strip() + if not visible_text: + return False + if len(visible_text) < 20 or not re.search(r"\s", visible_text): + return False + + return not self._has_natural_response_ending(visible_text) + def _looks_like_codex_intermediate_ack( self, user_message: str, @@ -9038,6 +9091,17 @@ class AIAgent: finish_reason = stop_reason_map.get(response.stop_reason, "stop") else: finish_reason = response.choices[0].finish_reason + assistant_message = response.choices[0].message + if self._should_treat_stop_as_truncated( + finish_reason, + assistant_message, + messages, + ): + self._vprint( + f"{self.log_prefix}⚠️ Treating suspicious Ollama/GLM stop response as truncated", + force=True, + ) + finish_reason = "length" if finish_reason == "length": self._vprint(f"{self.log_prefix}⚠️ Response truncated (finish_reason='length') - model hit max output tokens", force=True) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 5ff1491e4..7422f22f1 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2202,6 +2202,114 @@ class TestRunConversation: assert second_call_messages[-1]["role"] == "user" assert "truncated by the output length limit" in second_call_messages[-1]["content"] + def test_ollama_glm_stop_after_tools_without_terminal_boundary_requests_continuation(self, agent): + """Ollama-hosted GLM responses can misreport truncated output as stop.""" + self._setup_agent(agent) + agent.base_url = "http://localhost:11434/v1" + agent._base_url_lower = agent.base_url.lower() + agent.model = "glm-5.1:cloud" + + tool_turn = _mock_response( + content="", + finish_reason="tool_calls", + tool_calls=[_mock_tool_call(name="web_search", arguments="{}", call_id="c1")], + ) + misreported_stop = _mock_response( + content="Based on the search results, the best next", + finish_reason="stop", + ) + continued = _mock_response( + content=" step is to update the config.", + finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_turn, + misreported_stop, + continued, + ] + + with ( + patch("run_agent.handle_function_call", return_value="search result"), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert result["api_calls"] == 3 + assert ( + result["final_response"] + == "Based on the search results, the best next step is to update the config." + ) + + third_call_messages = agent.client.chat.completions.create.call_args_list[2].kwargs["messages"] + assert third_call_messages[-1]["role"] == "user" + assert "truncated by the output length limit" in third_call_messages[-1]["content"] + + def test_ollama_glm_stop_with_terminal_boundary_does_not_continue(self, agent): + """Complete Ollama/GLM responses should not be reclassified as truncated.""" + self._setup_agent(agent) + agent.base_url = "http://localhost:11434/v1" + agent._base_url_lower = agent.base_url.lower() + agent.model = "glm-5.1:cloud" + + tool_turn = _mock_response( + content="", + finish_reason="tool_calls", + tool_calls=[_mock_tool_call(name="web_search", arguments="{}", call_id="c1")], + ) + complete_stop = _mock_response( + content="Based on the search results, the best next step is to update the config.", + finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [tool_turn, complete_stop] + + with ( + patch("run_agent.handle_function_call", return_value="search result"), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert result["api_calls"] == 2 + assert ( + result["final_response"] + == "Based on the search results, the best next step is to update the config." + ) + + def test_non_ollama_stop_without_terminal_boundary_does_not_continue(self, agent): + """The stop->length workaround should stay scoped to Ollama/GLM backends.""" + self._setup_agent(agent) + agent.base_url = "https://api.openai.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.model = "gpt-4o-mini" + + tool_turn = _mock_response( + content="", + finish_reason="tool_calls", + tool_calls=[_mock_tool_call(name="web_search", arguments="{}", call_id="c1")], + ) + normal_stop = _mock_response( + content="Based on the search results, the best next", + finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [tool_turn, normal_stop] + + with ( + patch("run_agent.handle_function_call", return_value="search result"), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert result["api_calls"] == 2 + assert result["final_response"] == "Based on the search results, the best next" + def test_length_thinking_exhausted_skips_continuation(self, agent): """When finish_reason='length' but content is only thinking, skip retries.""" self._setup_agent(agent) From 3522a7aa135487882f7dea921b0b7456caf75154 Mon Sep 17 00:00:00 2001 From: Mibayy Date: Thu, 26 Mar 2026 12:10:27 +0000 Subject: [PATCH 285/849] feat(ollama): pass think=false to custom providers when reasoning_effort is none When a custom/Ollama provider is used and reasoning_effort is set to 'none' (or enabled: false), inject 'think': false into the request extra_body. Ollama does not recognise the OpenRouter-style 'reasoning' extra_body field, so thinking-capable models (Qwen3, etc.) generate blocks regardless of the reasoning_effort setting. This produces empty-response errors that corrupt session state. The fix adds a provider-specific block in _build_api_kwargs() that sets think=false in extra_body whenever self.provider == 'custom' and reasoning is explicitly disabled. Closes #3191 --- run_agent.py | 12 +++++++++ tests/run_agent/test_run_agent.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/run_agent.py b/run_agent.py index 625dc5fce..54f18d1a1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6688,6 +6688,18 @@ class AIAgent: options["num_ctx"] = self._ollama_num_ctx extra_body["options"] = options + # Ollama / custom provider: pass think=false when reasoning is disabled. + # Ollama does not recognise the OpenRouter-style `reasoning` extra_body + # field, so we use its native `think` parameter instead. + # This prevents thinking-capable models (Qwen3, etc.) from generating + # blocks and producing empty-response errors when the user has + # set reasoning_effort: none. + if self.provider == "custom" and self.reasoning_config and isinstance(self.reasoning_config, dict): + _effort = (self.reasoning_config.get("effort") or "").strip().lower() + _enabled = self.reasoning_config.get("enabled", True) + if _effort == "none" or _enabled is False: + extra_body["think"] = False + if self._is_qwen_portal(): extra_body["vl_high_resolution_images"] = True diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 7422f22f1..ee67f15b0 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -928,6 +928,7 @@ class TestBuildApiKwargs: kwargs = agent._build_api_kwargs(messages) assert kwargs["max_tokens"] == 4096 + def test_qwen_portal_formats_messages_and_metadata(self, agent): agent.base_url = "https://portal.qwen.ai/v1" agent._base_url_lower = agent.base_url.lower() @@ -983,6 +984,46 @@ class TestBuildApiKwargs: messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) assert kwargs["max_tokens"] == 65536 +======= + def test_ollama_think_false_on_effort_none(self, agent): + """Custom (Ollama) provider with effort=none should inject think=false.""" + agent.provider = "custom" + agent.base_url = "http://localhost:11434/v1" + agent._base_url_lower = agent.base_url.lower() + agent.reasoning_config = {"effort": "none"} + messages = [{"role": "user", "content": "hi"}] + kwargs = agent._build_api_kwargs(messages) + assert kwargs.get("extra_body", {}).get("think") is False + + def test_ollama_think_false_on_enabled_false(self, agent): + """Custom (Ollama) provider with enabled=false should inject think=false.""" + agent.provider = "custom" + agent.base_url = "http://localhost:11434/v1" + agent._base_url_lower = agent.base_url.lower() + agent.reasoning_config = {"enabled": False} + messages = [{"role": "user", "content": "hi"}] + kwargs = agent._build_api_kwargs(messages) + assert kwargs.get("extra_body", {}).get("think") is False + + def test_ollama_no_think_param_when_reasoning_enabled(self, agent): + """Custom provider with reasoning enabled should NOT inject think=false.""" + agent.provider = "custom" + agent.base_url = "http://localhost:11434/v1" + agent._base_url_lower = agent.base_url.lower() + agent.reasoning_config = {"enabled": True, "effort": "medium"} + messages = [{"role": "user", "content": "hi"}] + kwargs = agent._build_api_kwargs(messages) + assert kwargs.get("extra_body", {}).get("think") is None + + def test_non_custom_provider_unaffected(self, agent): + """OpenRouter provider with effort=none should NOT inject think=false.""" + agent.provider = "openrouter" + agent.model = "qwen/qwen3.5-plus-02-15" + agent.reasoning_config = {"effort": "none"} + messages = [{"role": "user", "content": "hi"}] + kwargs = agent._build_api_kwargs(messages) + assert kwargs.get("extra_body", {}).get("think") is None + class TestBuildAssistantMessage: From 8798b069d374b6a58b6c77e4a2f3c14871175768 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Sat, 4 Apr 2026 18:59:12 +0200 Subject: [PATCH 286/849] fix(agent): sanitize surrogate characters from API responses and before API calls --- run_agent.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 54f18d1a1..ec96ee86e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6822,9 +6822,16 @@ class AIAgent: except Exception: pass + # Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama) + # can return invalid surrogate code points that crash json.dumps() on persist. + _raw_content = assistant_message.content or "" + _san_content = _sanitize_surrogates(_raw_content) + if reasoning_text: + reasoning_text = _sanitize_surrogates(reasoning_text) + msg = { "role": "assistant", - "content": assistant_message.content or "", + "content": _san_content, "reasoning": reasoning_text, "finish_reason": finish_reason, } @@ -8705,6 +8712,12 @@ class AIAgent: new_tcs.append(tc) am["tool_calls"] = new_tcs + # Proactively strip any surrogate characters before the API call. + # Models served via Ollama (Kimi K2.5, GLM-5, Qwen) can return + # lone surrogates (U+D800-U+DFFF) that crash json.dumps() inside + # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle. + _sanitize_messages_surrogates(api_messages) + # Calculate approximate request size for logging total_chars = sum(len(str(msg)) for msg in api_messages) approx_tokens = estimate_messages_tokens_rough(api_messages) From 5c397876b9e1a91348d19fd0a94d14ed7d8857bf Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 22:36:35 -0700 Subject: [PATCH 287/849] fix(cli): hint about /v1 suffix when configuring local model endpoints When a user enters a local model server URL (Ollama, vLLM, llama.cpp) without a /v1 suffix during 'hermes model' custom endpoint setup, prompt them to add it. Most OpenAI-compatible local servers require /v1 in the base URL for chat completions to work. --- hermes_cli/main.py | 21 +++++++++++++++++++++ tests/run_agent/test_run_agent.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9d0615d53..f7b95ff38 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1568,6 +1568,27 @@ def _model_flow_custom(config): effective_key = api_key or current_key + # Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1 + # 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")) + if _looks_local and not _url_lower.endswith("/v1"): + print() + print(f" Hint: Did you mean to add /v1 at the end?") + print(f" Most local model servers (Ollama, vLLM, llama.cpp) require it.") + print(f" e.g. {effective_url.rstrip('/')}/v1") + try: + _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + _add_v1 = "n" + if _add_v1 in ("", "y", "yes"): + effective_url = effective_url.rstrip("/") + "/v1" + if base_url: + base_url = effective_url + print(f" Updated URL: {effective_url}") + print() + from hermes_cli.models import probe_api_models probe = probe_api_models(effective_key, effective_url) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index ee67f15b0..49ef1dc8f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -984,7 +984,7 @@ class TestBuildApiKwargs: messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) assert kwargs["max_tokens"] == 65536 -======= + def test_ollama_think_false_on_effort_none(self, agent): """Custom (Ollama) provider with effort=none should inject think=false.""" agent.provider = "custom" From 3c859e35dce3df797593a10581983cc86086cb55 Mon Sep 17 00:00:00 2001 From: nosleepcassette Date: Wed, 15 Apr 2026 22:35:59 -0700 Subject: [PATCH 288/849] fix: skin spinner faces and verbs not applied at runtime Skins define waiting_faces, thinking_faces, and thinking_verbs in their spinner config, but all 7 call sites in run_agent.py used hardcoded class constants. Add three classmethods on KawaiiSpinner that query the active skin first and fall back to the class constants, matching the existing pattern used for wings/tool_prefix/tool_emojis. Co-authored-by: nosleepcassette --- agent/display.py | 39 +++++++++++++++++++++++++++++++++++++++ run_agent.py | 14 +++++++------- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/agent/display.py b/agent/display.py index 063b7bb1c..a7f3cbaa2 100644 --- a/agent/display.py +++ b/agent/display.py @@ -600,6 +600,45 @@ class KawaiiSpinner: "analyzing", "computing", "synthesizing", "formulating", "brainstorming", ] + @classmethod + def get_waiting_faces(cls) -> list: + """Return waiting faces from the active skin, falling back to KAWAII_WAITING.""" + try: + skin = _get_skin() + if skin: + faces = skin.spinner.get("waiting_faces", []) + if faces: + return faces + except Exception: + pass + return cls.KAWAII_WAITING + + @classmethod + def get_thinking_faces(cls) -> list: + """Return thinking faces from the active skin, falling back to KAWAII_THINKING.""" + try: + skin = _get_skin() + if skin: + faces = skin.spinner.get("thinking_faces", []) + if faces: + return faces + except Exception: + pass + return cls.KAWAII_THINKING + + @classmethod + def get_thinking_verbs(cls) -> list: + """Return thinking verbs from the active skin, falling back to THINKING_VERBS.""" + try: + skin = _get_skin() + if skin: + verbs = skin.spinner.get("thinking_verbs", []) + if verbs: + return verbs + except Exception: + pass + return cls.THINKING_VERBS + def __init__(self, message: str = "", spinner_type: str = 'dots', print_fn=None): self.message = message self.spinner_frames = self.SPINNERS.get(spinner_type, self.SPINNERS['dots']) diff --git a/run_agent.py b/run_agent.py index ec96ee86e..2781bf188 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7490,7 +7490,7 @@ class AIAgent: # Start spinner for CLI mode (skip when TUI handles tool progress) spinner = None if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): - face = random.choice(KawaiiSpinner.KAWAII_WAITING) + face = random.choice(KawaiiSpinner.get_waiting_faces()) spinner = KawaiiSpinner(f"{face} ⚡ running {num_tools} tools concurrently", spinner_type='dots', print_fn=self._print_fn) spinner.start() @@ -7786,7 +7786,7 @@ class AIAgent: spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating" spinner = None if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): - face = random.choice(KawaiiSpinner.KAWAII_WAITING) + face = random.choice(KawaiiSpinner.get_waiting_faces()) spinner = KawaiiSpinner(f"{face} {spinner_label}", spinner_type='dots', print_fn=self._print_fn) spinner.start() self._delegate_spinner = spinner @@ -7813,7 +7813,7 @@ class AIAgent: # Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.) spinner = None if self.quiet_mode and not self.tool_progress_callback: - face = random.choice(KawaiiSpinner.KAWAII_WAITING) + face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) preview = _build_tool_preview(function_name, function_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn) @@ -7837,7 +7837,7 @@ class AIAgent: # These are not in the tool registry — route through MemoryManager. spinner = None if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): - face = random.choice(KawaiiSpinner.KAWAII_WAITING) + face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) preview = _build_tool_preview(function_name, function_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn) @@ -7859,7 +7859,7 @@ class AIAgent: elif self.quiet_mode: spinner = None if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner(): - face = random.choice(KawaiiSpinner.KAWAII_WAITING) + face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) preview = _build_tool_preview(function_name, function_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn) @@ -8731,8 +8731,8 @@ class AIAgent: self._vprint(f"{self.log_prefix} 🔧 Available tools: {len(self.tools) if self.tools else 0}") else: # Animated thinking spinner in quiet mode - face = random.choice(KawaiiSpinner.KAWAII_THINKING) - verb = random.choice(KawaiiSpinner.THINKING_VERBS) + face = random.choice(KawaiiSpinner.get_thinking_faces()) + verb = random.choice(KawaiiSpinner.get_thinking_verbs()) if self.thinking_callback: # CLI TUI mode: use prompt_toolkit widget instead of raw spinner # (works in both streaming and non-streaming modes) From 330ed12fb115efe350711260d5b8707769580929 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 22:37:46 -0700 Subject: [PATCH 289/849] chore: add nosleepcassette to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 53d42ea05..c5fa510c1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -64,6 +64,7 @@ AUTHOR_MAP = { "259807879+Bartok9@users.noreply.github.com": "Bartok9", "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", "268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1", + "27917469+nosleepcassette@users.noreply.github.com": "nosleepcassette", "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", # contributors (manual mapping from git names) "dmayhem93@gmail.com": "dmahan93", From 0c1217d01ec3a8420391e14ea859f97c95ee624d Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 22:27:26 -0700 Subject: [PATCH 290/849] feat(xai): upgrade to Responses API, add TTS provider Cherry-picked and trimmed from PR #10600 by Jaaneek. - Switch xAI transport from openai_chat to codex_responses (Responses API) - Add codex_responses detection for xAI in all runtime_provider resolution paths - Add xAI api_mode detection in AIAgent.__init__ (provider name + URL auto-detect) - Add extra_headers passthrough for codex_responses requests - Add x-grok-conv-id session header for xAI prompt caching - Add xAI reasoning support (encrypted_content include, no effort param) - Move x-grok-conv-id from chat_completions path to codex_responses path - Add xAI TTS provider (dedicated /v1/tts endpoint with Opus conversion) - Add xAI provider aliases (grok, x-ai, x.ai) across auth, models, providers, auxiliary - Trim xAI model list to agentic models (grok-4.20-reasoning, grok-4-1-fast-reasoning) - Add XAI_API_KEY/XAI_BASE_URL to OPTIONAL_ENV_VARS - Add xAI TTS config section, setup wizard entry, tools_config provider option - Add shared xai_http.py helper for User-Agent string Co-authored-by: Jaaneek --- agent/auxiliary_client.py | 3 ++ hermes_cli/auth.py | 1 + hermes_cli/config.py | 24 +++++++++- hermes_cli/main.py | 2 +- hermes_cli/models.py | 11 +---- hermes_cli/nous_subscription.py | 1 + hermes_cli/providers.py | 3 +- hermes_cli/runtime_provider.py | 8 ++++ hermes_cli/setup.py | 21 ++++++++- hermes_cli/tools_config.py | 8 ++++ run_agent.py | 38 ++++++++++++---- tools/tts_tool.py | 79 ++++++++++++++++++++++++++++++++- tools/xai_http.py | 12 +++++ toolsets.py | 2 +- 14 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 tools/xai_http.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 34d7d4250..bc6b1efbe 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -58,6 +58,9 @@ _PROVIDER_ALIASES = { "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", + "x-ai": "xai", + "x.ai": "xai", + "grok": "xai", "glm": "zai", "z-ai": "zai", "z.ai": "zai", diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 966082787..556e26f97 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -928,6 +928,7 @@ def resolve_provider( _PROVIDER_ALIASES = { "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", + "x-ai": "xai", "x.ai": "xai", "grok": "xai", "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", "arcee-ai": "arcee", "arceeai": "arcee", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7f639726f..a85997f8f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -566,7 +566,7 @@ DEFAULT_CONFIG = { # Text-to-speech configuration "tts": { - "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "minimax" | "mistral" | "neutts" (local) + "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "neutts" (local) "edge": { "voice": "en-US-AriaNeural", # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural @@ -580,6 +580,12 @@ DEFAULT_CONFIG = { "voice": "alloy", # Voices: alloy, echo, fable, onyx, nova, shimmer }, + "xai": { + "voice_id": "eve", + "language": "en", + "sample_rate": 24000, + "bit_rate": 128000, + }, "mistral": { "model": "voxtral-mini-tts-2603", "voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral @@ -836,6 +842,22 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "XAI_API_KEY": { + "description": "xAI API key", + "prompt": "xAI API key", + "url": "https://console.x.ai/", + "password": True, + "category": "provider", + "advanced": True, + }, + "XAI_BASE_URL": { + "description": "xAI base URL override", + "prompt": "xAI base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "GLM_API_KEY": { "description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)", "prompt": "Z.AI / GLM API key", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f7b95ff38..d1ee08c49 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4890,7 +4890,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "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)" ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 9812fc97e..a298dc99c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -145,17 +145,8 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "glm-4.5-flash", ], "xai": [ - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-4.20-multi-agent-0309", + "grok-4.20-reasoning", "grok-4-1-fast-reasoning", - "grok-4-1-fast-non-reasoning", - "grok-4-fast-reasoning", - "grok-4-fast-non-reasoning", - "grok-4-0709", - "grok-code-fast-1", - "grok-3", - "grok-3-mini", ], "kimi-coding": [ "kimi-for-coding", diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f1e4366c1..e182b37e7 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -143,6 +143,7 @@ def _tts_label(current_provider: str) -> str: "openai": "OpenAI TTS", "elevenlabs": "ElevenLabs", "edge": "Edge TTS", + "xai": "xAI TTS", "mistral": "Mistral Voxtral TTS", "neutts": "NeuTTS", } diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index eae832055..8b5b35fe5 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -128,7 +128,7 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_env_var="HF_BASE_URL", ), "xai": HermesOverlay( - transport="openai_chat", + transport="codex_responses", base_url_override="https://api.x.ai/v1", base_url_env_var="XAI_BASE_URL", ), @@ -184,6 +184,7 @@ ALIASES: Dict[str, str] = { # xai "x-ai": "xai", "x.ai": "xai", + "grok": "xai", # kimi-for-coding (models.dev ID) "kimi": "kimi-for-coding", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 33b35562f..ffd97a6ca 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -41,6 +41,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: tool calls with reasoning (chat/completions returns 400). """ normalized = (base_url or "").strip().lower().rstrip("/") + if "api.x.ai" in normalized: + return "codex_responses" if "api.openai.com" in normalized and "openrouter" not in normalized: return "codex_responses" return None @@ -163,6 +165,8 @@ def _resolve_runtime_from_pool_entry( base_url = cfg_base_url or base_url or "https://api.anthropic.com" elif provider == "openrouter": base_url = base_url or OPENROUTER_BASE_URL + elif provider == "xai": + api_mode = "codex_responses" elif provider == "nous": api_mode = "chat_completions" elif provider == "copilot": @@ -628,6 +632,8 @@ def _resolve_explicit_runtime( api_mode = "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, api_key) + elif provider == "xai": + api_mode = "codex_responses" else: configured_mode = _parse_api_mode(model_cfg.get("api_mode")) if configured_mode: @@ -924,6 +930,8 @@ def resolve_runtime_provider( api_mode = "chat_completions" if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) + elif provider == "xai": + api_mode = "codex_responses" else: configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Only honor persisted api_mode when it belongs to the same provider family. diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 52f6e36d6..eafe3b633 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -920,6 +920,7 @@ def _setup_tts_provider(config: dict): "edge": "Edge TTS", "elevenlabs": "ElevenLabs", "openai": "OpenAI TTS", + "xai": "xAI TTS", "minimax": "MiniMax TTS", "mistral": "Mistral Voxtral TTS", "neutts": "NeuTTS", @@ -941,12 +942,13 @@ def _setup_tts_provider(config: dict): "Edge TTS (free, cloud-based, no setup needed)", "ElevenLabs (premium quality, needs API key)", "OpenAI TTS (good quality, needs API key)", + "xAI TTS (Grok voices, needs API key)", "MiniMax TTS (high quality with voice cloning, needs API key)", "Mistral Voxtral TTS (multilingual, native Opus, needs API key)", "NeuTTS (local on-device, free, ~300MB model download)", ] ) - providers.extend(["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]) + providers.extend(["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "neutts"]) choices.append(f"Keep current ({current_label})") keep_current_idx = len(choices) - 1 idx = prompt_choice("Select TTS provider:", choices, keep_current_idx) @@ -1012,6 +1014,23 @@ def _setup_tts_provider(config: dict): print_warning("No API key provided. Falling back to Edge TTS.") selected = "edge" + elif selected == "xai": + existing = get_env_value("XAI_API_KEY") + if not existing: + print() + api_key = prompt("xAI API key for TTS", password=True) + if api_key: + save_env_value("XAI_API_KEY", api_key) + print_success("xAI TTS API key saved") + else: + from hermes_constants import display_hermes_home as _dhh + print_warning( + "No xAI API key provided for TTS. Configure XAI_API_KEY via " + f"hermes setup model or {_dhh()}/.env to use xAI TTS. " + "Falling back to Edge TTS." + ) + selected = "edge" + elif selected == "minimax": existing = get_env_value("MINIMAX_API_KEY") if not existing: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5fe8cdc79..0609e7ff4 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -146,6 +146,14 @@ TOOL_CATEGORIES = { ], "tts_provider": "openai", }, + { + "name": "xAI TTS", + "tag": "Grok voices - requires xAI API key", + "env_vars": [ + {"key": "XAI_API_KEY", "prompt": "xAI API key", "url": "https://console.x.ai/"}, + ], + "tts_provider": "xai", + }, { "name": "ElevenLabs", "badge": "paid", diff --git a/run_agent.py b/run_agent.py index 2781bf188..cb5dbf4b1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -691,9 +691,14 @@ class AIAgent: self.api_mode = api_mode elif self.provider == "openai-codex": self.api_mode = "codex_responses" + elif self.provider == "xai": + self.api_mode = "codex_responses" elif (provider_name is None) and "chatgpt.com/backend-api/codex" in self._base_url_lower: self.api_mode = "codex_responses" self.provider = "openai-codex" + elif (provider_name is None) and "api.x.ai" in self._base_url_lower: + self.api_mode = "codex_responses" + self.provider = "xai" elif self.provider == "anthropic" or (provider_name is None and "api.anthropic.com" in self._base_url_lower): self.api_mode = "anthropic_messages" self.provider = "anthropic" @@ -4032,6 +4037,7 @@ class AIAgent: "model", "instructions", "input", "tools", "store", "reasoning", "include", "max_output_tokens", "temperature", "tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier", + "extra_headers", } normalized: Dict[str, Any] = { "model": model, @@ -4067,6 +4073,20 @@ class AIAgent: if val is not None: normalized[passthrough_key] = val + extra_headers = api_kwargs.get("extra_headers") + if extra_headers is not None: + if not isinstance(extra_headers, dict): + raise ValueError("Codex Responses request 'extra_headers' must be an object.") + normalized_headers: Dict[str, str] = {} + for key, value in extra_headers.items(): + if not isinstance(key, str) or not key.strip(): + raise ValueError("Codex Responses request 'extra_headers' keys must be non-empty strings.") + if value is None: + continue + normalized_headers[key.strip()] = str(value) + if normalized_headers: + normalized["extra_headers"] = normalized_headers + if allow_stream: stream = api_kwargs.get("stream") if stream is not None and stream is not True: @@ -6504,7 +6524,12 @@ class AIAgent: if not is_github_responses: kwargs["prompt_cache_key"] = self.session_id - if reasoning_enabled: + is_xai_responses = self.provider == "xai" or "api.x.ai" in (self.base_url or "").lower() + + if reasoning_enabled and is_xai_responses: + # xAI reasons automatically — no effort param, just include encrypted content + kwargs["include"] = ["reasoning.encrypted_content"] + elif reasoning_enabled: if is_github_responses: # Copilot's Responses route advertises reasoning-effort support, # but not OpenAI-specific prompt cache or encrypted reasoning @@ -6515,7 +6540,7 @@ class AIAgent: else: kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} kwargs["include"] = ["reasoning.encrypted_content"] - elif not is_github_responses: + elif not is_github_responses and not is_xai_responses: kwargs["include"] = [] if self.request_overrides: @@ -6524,6 +6549,9 @@ class AIAgent: if self.max_tokens is not None and not is_codex_backend: kwargs["max_output_tokens"] = self.max_tokens + if is_xai_responses and getattr(self, "session_id", None): + kwargs["extra_headers"] = {"x-grok-conv-id": self.session_id} + return kwargs sanitized_messages = api_messages @@ -6706,12 +6734,6 @@ class AIAgent: if extra_body: api_kwargs["extra_body"] = extra_body - # xAI prompt caching: send x-grok-conv-id header to route requests - # to the same server, maximizing automatic cache hits. - # https://docs.x.ai/developers/advanced-api-usage/prompt-caching - if "x.ai" in self._base_url_lower and hasattr(self, "session_id") and self.session_id: - api_kwargs["extra_headers"] = {"x-grok-conv-id": self.session_id} - # Priority Processing / generic request overrides (e.g. service_tier). # Applied last so overrides win over any defaults set above. if self.request_overrides: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 9fdb63866..65ff725ee 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -45,6 +45,7 @@ from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key +from tools.xai_http import hermes_xai_user_agent # --------------------------------------------------------------------------- # Lazy imports -- providers are imported only when actually used to avoid @@ -93,6 +94,11 @@ DEFAULT_MINIMAX_VOICE_ID = "English_Graceful_Lady" DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2" DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603" DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral +DEFAULT_XAI_VOICE_ID = "eve" +DEFAULT_XAI_LANGUAGE = "en" +DEFAULT_XAI_SAMPLE_RATE = 24000 +DEFAULT_XAI_BIT_RATE = 128000 +DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" def _get_default_output_dir() -> str: from hermes_constants import get_hermes_dir @@ -299,6 +305,71 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] close() +# =========================================================================== +# Provider: xAI TTS +# =========================================================================== +def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: + """ + Generate audio using xAI TTS. + + xAI exposes a dedicated /v1/tts endpoint instead of the OpenAI audio.speech + API shape, so this is implemented as a separate backend. + """ + import requests + + api_key = os.getenv("XAI_API_KEY", "").strip() + if not api_key: + raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/") + + xai_config = tts_config.get("xai", {}) + voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID + language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE + sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE)) + bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE)) + base_url = str( + xai_config.get("base_url") + or os.getenv("XAI_BASE_URL") + or DEFAULT_XAI_BASE_URL + ).strip().rstrip("/") + + # Match the documented minimal POST /v1/tts shape by default. Only send + # output_format when Hermes actually needs a non-default format/override. + codec = "wav" if output_path.endswith(".wav") else "mp3" + payload: Dict[str, Any] = { + "text": text, + "voice_id": voice_id, + "language": language, + } + if ( + codec != "mp3" + or sample_rate != DEFAULT_XAI_SAMPLE_RATE + or (codec == "mp3" and bit_rate != DEFAULT_XAI_BIT_RATE) + ): + output_format: Dict[str, Any] = {"codec": codec} + if sample_rate: + output_format["sample_rate"] = sample_rate + if codec == "mp3" and bit_rate: + output_format["bit_rate"] = bit_rate + payload["output_format"] = output_format + + response = requests.post( + f"{base_url}/tts", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "User-Agent": hermes_xai_user_agent(), + }, + json=payload, + timeout=60, + ) + response.raise_for_status() + + with open(output_path, "wb") as f: + f.write(response.content) + + return output_path + + # =========================================================================== # Provider: MiniMax TTS # =========================================================================== @@ -600,6 +671,10 @@ def text_to_speech_tool( logger.info("Generating speech with MiniMax TTS...") _generate_minimax_tts(text, file_str, tts_config) + elif provider == "xai": + logger.info("Generating speech with xAI TTS...") + _generate_xai_tts(text, file_str, tts_config) + elif provider == "mistral": try: _import_mistral_client() @@ -661,7 +736,7 @@ def text_to_speech_tool( # Try Opus conversion for Telegram compatibility # Edge TTS outputs MP3, NeuTTS outputs WAV — both need ffmpeg conversion voice_compatible = False - if provider in ("edge", "neutts", "minimax") and not file_str.endswith(".ogg"): + if provider in ("edge", "neutts", "minimax", "xai") and not file_str.endswith(".ogg"): opus_path = _convert_to_opus(file_str) if opus_path: file_str = opus_path @@ -734,6 +809,8 @@ def check_tts_requirements() -> bool: pass if os.getenv("MINIMAX_API_KEY"): return True + if os.getenv("XAI_API_KEY"): + return True try: _import_mistral_client() if os.getenv("MISTRAL_API_KEY"): diff --git a/tools/xai_http.py b/tools/xai_http.py new file mode 100644 index 000000000..b5bce97c2 --- /dev/null +++ b/tools/xai_http.py @@ -0,0 +1,12 @@ +"""Shared helpers for direct xAI HTTP integrations.""" + +from __future__ import annotations + + +def hermes_xai_user_agent() -> str: + """Return a stable Hermes-specific User-Agent for xAI HTTP calls.""" + try: + from hermes_cli import __version__ + except Exception: + __version__ = "unknown" + return f"Hermes-Agent/{__version__}" diff --git a/toolsets.py b/toolsets.py index 09ee8de09..b725133a6 100644 --- a/toolsets.py +++ b/toolsets.py @@ -151,7 +151,7 @@ TOOLSETS = { }, "tts": { - "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, or OpenAI", + "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", "tools": ["text_to_speech"], "includes": [] }, From e4cd62d07df101fa9748b650435d52f0c36f52eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:26:14 -0700 Subject: [PATCH 291/849] =?UTF-8?q?fix(tests):=20resolve=20remaining=20CI?= =?UTF-8?q?=20failures=20=E2=80=94=20commit=5Fmemory=5Fsession,=20already?= =?UTF-8?q?=5Fsent,=20timezone=20leak,=20session=20env=20(#10785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 12 CI test failures: 1. test_cli_new_session (4): _FakeAgent missing commit_memory_session attribute added in the memory provider refactoring. Added MagicMock. 2. test_run_progress_topics (1): already_sent detection only checked stream consumer flags, missing the response_previewed path from interim_assistant_callback. Restructured guard to check both paths. 3. test_timezone (1): HERMES_TIMEZONE leaked into child processes via _SAFE_ENV_PREFIXES matching HERMES_*. The code correctly converts it to TZ but didn't remove the original. Added child_env.pop(). 4. test_session_env (1): contextvars baseline captured from a different context couldn't be restored after clear. Changed assertion to verify the test's value was removed rather than comparing to a fragile baseline. 5. test_discord_slash_commands (5): already fixed on current main. --- gateway/run.py | 10 +++++++--- tests/cli/test_cli_new_session.py | 1 + tests/gateway/test_session_env.py | 6 ++++-- tools/code_execution_tool.py | 5 ++++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 28a350a39..4a1539927 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9472,13 +9472,17 @@ class GatewayRunner: # final answer. Suppressing delivery here leaves the user staring # at silence. (#10xxx — "agent stops after web search") _sc = stream_consumer_holder[0] - if _sc and isinstance(response, dict) and not response.get("failed"): + if isinstance(response, dict) and not response.get("failed"): _final = response.get("final_response") or "" _is_empty_sentinel = not _final or _final == "(empty)" - if not _is_empty_sentinel and ( + _streamed = _sc and ( getattr(_sc, "final_response_sent", False) or getattr(_sc, "already_sent", False) - ): + ) + # response_previewed means the interim_assistant_callback already + # sent the final text via the adapter (non-streaming path). + _previewed = bool(response.get("response_previewed")) + if not _is_empty_sentinel and (_streamed or _previewed): response["already_sent"] = True return response diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index 0490aad9c..dbfc07db2 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -34,6 +34,7 @@ class _FakeAgent: [{"id": "t1", "content": "unfinished task", "status": "in_progress"}] ) self.flush_memories = MagicMock() + self.commit_memory_session = MagicMock() self._invalidate_system_prompt = MagicMock() # Token counters (non-zero to verify reset) diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index c4765c144..2b6c983a7 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -209,11 +209,13 @@ def test_set_session_env_includes_session_key(): # Capture baseline value before setting (may be non-empty from another # test in the same pytest-xdist worker sharing the context). - baseline = get_session_env("HERMES_SESSION_KEY") tokens = runner._set_session_env(context) assert get_session_env("HERMES_SESSION_KEY") == "tg:-1001:17585" runner._clear_session_env(tokens) - assert get_session_env("HERMES_SESSION_KEY") == baseline + # After clearing, the session key must not retain the value we just set. + # The exact post-clear value depends on context propagation from other + # tests, so only check that our value was removed, not what replaced it. + assert get_session_env("HERMES_SESSION_KEY") != "tg:-1001:17585" def test_session_key_no_race_condition_with_contextvars(monkeypatch): diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 723bc400d..8cffeda80 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -1016,10 +1016,13 @@ def execute_code( _existing_pp = child_env.get("PYTHONPATH", "") child_env["PYTHONPATH"] = _hermes_root + (os.pathsep + _existing_pp if _existing_pp else "") # Inject user's configured timezone so datetime.now() in sandboxed - # code reflects the correct wall-clock time. + # code reflects the correct wall-clock time. Only TZ is set — + # HERMES_TIMEZONE is an internal Hermes setting and must not leak + # into child processes. _tz_name = os.getenv("HERMES_TIMEZONE", "").strip() if _tz_name: child_env["TZ"] = _tz_name + child_env.pop("HERMES_TIMEZONE", None) # Per-profile HOME isolation: redirect system tool configs into # {HERMES_HOME}/home/ when that directory exists. From f2f9d0c81905758e316249b49629ed3ca716c220 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:27:20 -0700 Subject: [PATCH 292/849] fix: stop /model from silently rerouting direct providers to OpenRouter (#10300) (#10780) detect_provider_for_model() silently remapped models to OpenRouter when the direct provider's credentials weren't found via env vars. Three bugs: 1. Credential check only looked at env vars from PROVIDER_REGISTRY, missing credential pool entries, auth store, and OAuth tokens 2. When env var check failed, silently returned ('openrouter', slug) instead of the direct provider the model actually belongs to 3. Users with valid credentials via non-env-var mechanisms (pool, OAuth, Claude Code tokens) got silently rerouted Fix: - Expand credential check to also query credential pool and auth store - Always return the direct provider match regardless of credential status -- let client init handle missing creds with a clear error rather than silently routing through the wrong provider Same philosophy as the provider-required fix: don't guess, don't silently reroute, error clearly when something is missing. Closes #10300 --- hermes_cli/models.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a298dc99c..0ae32f11a 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1061,7 +1061,8 @@ def detect_provider_for_model( break if direct_match: - # Check if we have credentials for this provider + # Check if we have credentials for this provider — env vars, + # credential pool, or auth store entries. has_creds = False try: from hermes_cli.auth import PROVIDER_REGISTRY @@ -1074,16 +1075,28 @@ def detect_provider_for_model( break except Exception: pass + # Also check credential pool and auth store — covers OAuth, + # Claude Code tokens, and other non-env-var credentials (#10300). + if not has_creds: + try: + from agent.credential_pool import load_pool + pool = load_pool(direct_match) + if pool.has_credentials(): + has_creds = True + except Exception: + pass + if not has_creds: + try: + from hermes_cli.auth import _load_auth_store + store = _load_auth_store() + if direct_match in store.get("providers", {}) or direct_match in store.get("credential_pool", {}): + has_creds = True + except Exception: + pass - if has_creds: - return (direct_match, name) - - # No direct creds — try to find this model on OpenRouter instead - or_slug = _find_openrouter_slug(name) - if or_slug: - return ("openrouter", or_slug) - # Still return the direct provider — credential resolution will - # give a clear error rather than silently using the wrong provider + # Always return the direct provider match. If credentials are + # missing, the client init will give a clear error rather than + # silently routing through the wrong provider (#10300). return (direct_match, name) # --- Step 2: check OpenRouter catalog --- From 12b109b6640a573abf685d3c881cab2a9fc5c3aa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:32:21 -0700 Subject: [PATCH 293/849] fix: enable TCP keepalives to detect dead provider connections (#10324) (#10933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a custom provider drops a connection mid-stream, the TCP socket can enter CLOSE-WAIT and the httpx read timeout may never fire — epoll_wait blocks indefinitely because no data or error signal arrives. The agent hangs until manually killed. The existing defenses (httpx read timeout, stale stream detector, _force_close_tcp_sockets) are all time-based and work correctly once triggered, but they rely on the socket layer reporting the dead connection. Without TCP keepalives, the kernel has no reason to probe a silent connection. Fix: inject SO_KEEPALIVE + TCP_KEEPIDLE/KEEPINTVL/KEEPCNT into the httpx transport via socket_options. The kernel probes idle connections after 30s, retries every 10s, gives up after 3 failures — dead peer detected within ~60s instead of hanging forever. Platform-aware: uses TCP_KEEPIDLE on Linux, TCP_KEEPALIVE on macOS. Falls back silently if socket options aren't available (Windows, etc.). Closes #10324 --- run_agent.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/run_agent.py b/run_agent.py index cb5dbf4b1..944217e6b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4366,6 +4366,29 @@ class AIAgent: self._client_log_context(), ) return client + # Inject TCP keepalives to detect dead connections faster (#10324). + # Without keepalives, a provider that drops mid-stream leaves the + # socket in CLOSE-WAIT and epoll_wait may never fire, causing the + # agent to hang indefinitely. Keepalive probes detect the dead + # peer within ~60s (30s idle + 3×10s probes). + if "http_client" not in client_kwargs: + try: + import httpx as _httpx + import socket as _socket + _sock_opts = [(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)] + if hasattr(_socket, "TCP_KEEPIDLE"): + # Linux + _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPIDLE, 30)) + _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPINTVL, 10)) + _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPCNT, 3)) + elif hasattr(_socket, "TCP_KEEPALIVE"): + # macOS (uses TCP_KEEPALIVE instead of TCP_KEEPIDLE) + _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30)) + client_kwargs["http_client"] = _httpx.Client( + transport=_httpx.HTTPTransport(socket_options=_sock_opts), + ) + except Exception: + pass # Fall through to default transport if socket opts fail client = OpenAI(**client_kwargs) logger.info( "OpenAI client created (%s, shared=%s) %s", From 9a9b8cd1e4dff30e28eb7945a157883fb4f91fec Mon Sep 17 00:00:00 2001 From: Peter Berthelsen Date: Tue, 14 Apr 2026 16:27:12 -0400 Subject: [PATCH 294/849] fix: keep rapid telegram follow-ups from getting cut off --- gateway/platforms/base.py | 55 +++++++++++++---- gateway/run.py | 33 +++++++++- tests/gateway/test_session_race_guard.py | 78 +++++++++++++++++++++++- 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ddee844f4..c18d3569d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -734,25 +734,56 @@ def merge_pending_message_event( pending_messages: Dict[str, MessageEvent], session_key: str, event: MessageEvent, + *, + merge_text: bool = False, ) -> None: """Store or merge a pending event for a session. Photo bursts/albums often arrive as multiple near-simultaneous PHOTO events. Merge those into the existing queued event so the next turn sees - the whole burst, while non-photo follow-ups still replace the pending - event normally. + the whole burst. + + When ``merge_text`` is enabled, rapid follow-up TEXT events are appended + instead of replacing the pending turn. This is used for Telegram bursty + follow-ups so a multi-part user thought is not silently truncated to only + the last queued fragment. """ existing = pending_messages.get(session_key) - if ( - existing - and getattr(existing, "message_type", None) == MessageType.PHOTO - and event.message_type == MessageType.PHOTO - ): - existing.media_urls.extend(event.media_urls) - existing.media_types.extend(event.media_types) - if event.text: - existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text) - return + if existing: + existing_is_photo = getattr(existing, "message_type", None) == MessageType.PHOTO + incoming_is_photo = event.message_type == MessageType.PHOTO + existing_has_media = bool(existing.media_urls) + incoming_has_media = bool(event.media_urls) + + if existing_is_photo and incoming_is_photo: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text) + return + + if existing_has_media or incoming_has_media: + if incoming_has_media: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + if existing.text: + existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text) + else: + existing.text = event.text + if existing_is_photo or incoming_is_photo: + existing.message_type = MessageType.PHOTO + return + + if ( + merge_text + and getattr(existing, "message_type", None) == MessageType.TEXT + and event.message_type == MessageType.TEXT + ): + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + return + pending_messages[session_key] = event diff --git a/gateway/run.py b/gateway/run.py index 4a1539927..13f4cb647 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2922,6 +2922,32 @@ class GatewayRunner: merge_pending_message_event(adapter._pending_messages, _quick_key, event) return None + _telegram_followup_grace = float( + os.getenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0") + ) + _started_at = self._running_agents_ts.get(_quick_key, 0) + if ( + source.platform == Platform.TELEGRAM + and event.message_type == MessageType.TEXT + and _telegram_followup_grace > 0 + and _started_at + and (time.time() - _started_at) <= _telegram_followup_grace + ): + logger.debug( + "Telegram follow-up arrived %.2fs after run start for %s — queueing without interrupt", + time.time() - _started_at, + _quick_key[:20], + ) + adapter = self.adapters.get(source.platform) + if adapter: + merge_pending_message_event( + adapter._pending_messages, + _quick_key, + event, + merge_text=True, + ) + return None + running_agent = self._running_agents.get(_quick_key) if running_agent is _AGENT_PENDING_SENTINEL: # Agent is being set up but not ready yet. @@ -2935,7 +2961,12 @@ class GatewayRunner: # agent starts. adapter = self.adapters.get(source.platform) if adapter: - adapter._pending_messages[_quick_key] = event + merge_pending_message_event( + adapter._pending_messages, + _quick_key, + event, + merge_text=True, + ) return None if self._draining: if self._queue_during_drain_enabled(): diff --git a/tests/gateway/test_session_race_guard.py b/tests/gateway/test_session_race_guard.py index fcfaba784..d7eeff5c1 100644 --- a/tests/gateway/test_session_race_guard.py +++ b/tests/gateway/test_session_race_guard.py @@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig -from gateway.platforms.base import MessageEvent, MessageType +from gateway.platforms.base import MessageEvent, MessageType, merge_pending_message_event from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL from gateway.session import SessionSource, build_session_key @@ -184,6 +184,80 @@ async def test_second_message_during_sentinel_queued_not_duplicate(): await task1 +def test_merge_pending_message_event_merges_text_and_photo_followups(): + pending = {} + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + user_id="u1", + ) + session_key = build_session_key(source) + + text_event = MessageEvent( + text="first follow-up", + message_type=MessageType.TEXT, + source=source, + ) + photo_event = MessageEvent( + text="see screenshot", + message_type=MessageType.PHOTO, + source=source, + media_urls=["/tmp/test.png"], + media_types=["image/png"], + ) + + merge_pending_message_event(pending, session_key, text_event, merge_text=True) + merge_pending_message_event(pending, session_key, photo_event, merge_text=True) + + merged = pending[session_key] + assert merged.message_type == MessageType.PHOTO + assert merged.text == "first follow-up\n\nsee screenshot" + assert merged.media_urls == ["/tmp/test.png"] + assert merged.media_types == ["image/png"] + + +@pytest.mark.asyncio +async def test_recent_telegram_text_followup_is_queued_without_interrupt(): + runner = _make_runner() + event = _make_event(text="follow-up") + session_key = build_session_key(event.source) + + fake_agent = MagicMock() + fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0} + runner._running_agents[session_key] = fake_agent + import time as _time + runner._running_agents_ts[session_key] = _time.time() + + result = await runner._handle_message(event) + + assert result is None + fake_agent.interrupt.assert_not_called() + adapter = runner.adapters[Platform.TELEGRAM] + assert adapter._pending_messages[session_key].text == "follow-up" + + +@pytest.mark.asyncio +async def test_recent_telegram_followups_append_in_pending_queue(): + runner = _make_runner() + first = _make_event(text="part one") + second = _make_event(text="part two") + session_key = build_session_key(first.source) + + fake_agent = MagicMock() + fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0} + runner._running_agents[session_key] = fake_agent + import time as _time + runner._running_agents_ts[session_key] = _time.time() + + await runner._handle_message(first) + await runner._handle_message(second) + + fake_agent.interrupt.assert_not_called() + adapter = runner.adapters[Platform.TELEGRAM] + assert adapter._pending_messages[session_key].text == "part one\npart two" + + # ------------------------------------------------------------------ # Test 5: Sentinel not placed for command messages # ------------------------------------------------------------------ @@ -273,6 +347,7 @@ async def test_stop_hard_kills_running_agent(): # Simulate a running (possibly hung) agent fake_agent = MagicMock() + fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0} runner._running_agents[session_key] = fake_agent # Send /stop @@ -305,6 +380,7 @@ async def test_stop_clears_pending_messages(): ) fake_agent = MagicMock() + fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0} runner._running_agents[session_key] = fake_agent runner._pending_messages[session_key] = "some queued text" From 3f6c4346acb5d2ddb398df068e085d7a1cab8465 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 20:11:51 -0700 Subject: [PATCH 295/849] feat: dashboard theme system with live switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a theme engine for the web dashboard that mirrors the CLI skin engine philosophy — pure data, no code changes needed for new themes. Frontend: - ThemeProvider context that loads active theme from backend on mount and applies CSS variable overrides to document.documentElement - ThemeSwitcher dropdown component in the header (next to language switcher) with instant preview on click - 6 built-in themes: Hermes Teal (default), Midnight, Ember, Mono, Cyberpunk, Rosé — each defines all 21 color tokens + overlay settings - Theme types, presets, and context in web/src/themes/ Backend: - GET /api/dashboard/themes — returns available themes + active name - PUT /api/dashboard/theme — persists selection to config.yaml - User custom themes discoverable from ~/.hermes/dashboard-themes/*.yaml - Theme list endpoint added to public API paths (no auth needed) Config: - dashboard.theme key in DEFAULT_CONFIG (default: 'default') - Schema override for select dropdown in config page - Category merged into 'display' tab in config UI i18n: theme switcher strings added for en + zh. --- hermes_cli/config.py | 5 + hermes_cli/web_server.py | 77 +++++++++ web/src/App.tsx | 2 + web/src/components/ThemeSwitcher.tsx | 115 ++++++++++++++ web/src/i18n/en.ts | 5 + web/src/i18n/types.ts | 6 + web/src/i18n/zh.ts | 5 + web/src/lib/api.ts | 17 ++ web/src/main.tsx | 5 +- web/src/themes/context.tsx | 169 ++++++++++++++++++++ web/src/themes/index.ts | 3 + web/src/themes/presets.ts | 229 +++++++++++++++++++++++++++ web/src/themes/types.ts | 44 +++++ 13 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 web/src/components/ThemeSwitcher.tsx create mode 100644 web/src/themes/context.tsx create mode 100644 web/src/themes/index.ts create mode 100644 web/src/themes/presets.ts create mode 100644 web/src/themes/types.ts diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a85997f8f..eed9d5c3a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -559,6 +559,11 @@ DEFAULT_CONFIG = { "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} }, + # Web dashboard settings + "dashboard": { + "theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose" + }, + # Privacy settings "privacy": { "redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 22265faa5..4d39d379b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -96,6 +96,7 @@ _PUBLIC_API_PATHS: frozenset = frozenset({ "/api/config/defaults", "/api/config/schema", "/api/model/info", + "/api/dashboard/themes", }) @@ -166,6 +167,11 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "CLI visual theme", "options": ["default", "ares", "mono", "slate"], }, + "dashboard.theme": { + "type": "select", + "description": "Web dashboard visual theme", + "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], + }, "display.resume_display": { "type": "select", "description": "How resumed sessions display history", @@ -224,6 +230,7 @@ _CATEGORY_MERGE: Dict[str, str] = { "approvals": "security", "human_delay": "display", "smart_model_routing": "agent", + "dashboard": "display", } # Display order for tabs — unlisted categories sort alphabetically after these. @@ -2068,6 +2075,76 @@ def mount_spa(application: FastAPI): return _serve_index() +# --------------------------------------------------------------------------- +# Dashboard theme endpoints +# --------------------------------------------------------------------------- + +# Built-in dashboard themes — label + description only. The actual color +# definitions live in the frontend (web/src/themes/presets.ts). +_BUILTIN_DASHBOARD_THEMES = [ + {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, + {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, + {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, + {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, + {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, + {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, +] + + +def _discover_user_themes() -> list: + """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes.""" + themes_dir = get_hermes_home() / "dashboard-themes" + if not themes_dir.is_dir(): + return [] + result = [] + for f in sorted(themes_dir.glob("*.yaml")): + try: + data = yaml.safe_load(f.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("name"): + result.append({ + "name": data["name"], + "label": data.get("label", data["name"]), + "description": data.get("description", ""), + }) + except Exception: + continue + return result + + +@app.get("/api/dashboard/themes") +async def get_dashboard_themes(): + """Return available themes and the currently active one.""" + config = load_config() + active = config.get("dashboard", {}).get("theme", "default") + user_themes = _discover_user_themes() + # Merge built-in + user, user themes override built-in by name. + seen = set() + themes = [] + for t in _BUILTIN_DASHBOARD_THEMES: + seen.add(t["name"]) + themes.append(t) + for t in user_themes: + if t["name"] not in seen: + themes.append(t) + seen.add(t["name"]) + return {"themes": themes, "active": active} + + +class ThemeSetBody(BaseModel): + name: str + + +@app.put("/api/dashboard/theme") +async def set_dashboard_theme(body: ThemeSetBody): + """Set the active dashboard theme (persists to config.yaml).""" + config = load_config() + if "dashboard" not in config: + config["dashboard"] = {} + config["dashboard"]["theme"] = body.name + save_config(config) + return {"ok": True, "theme": body.name} + + mount_spa(app) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4bbc13fac..dfadf1067 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; const NAV_ITEMS = [ @@ -67,6 +68,7 @@ export default function App() {
+ {t.app.webUi} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx new file mode 100644 index 000000000..03801bebf --- /dev/null +++ b/web/src/components/ThemeSwitcher.tsx @@ -0,0 +1,115 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { Palette, Check } from "lucide-react"; +import { useTheme } from "@/themes"; +import { useI18n } from "@/i18n"; +import { cn } from "@/lib/utils"; + +/** + * Compact theme picker for the dashboard header. + * Shows a palette icon + current theme name; opens a dropdown of all + * available themes with color swatches for instant preview. + */ +export function ThemeSwitcher() { + const { themeName, availableThemes, setTheme } = useTheme(); + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const close = useCallback(() => setOpen(false), []); + + // Close on outside click. + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) close(); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open, close]); + + // Close on Escape. + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [open, close]); + + const current = availableThemes.find((t) => t.name === themeName); + + return ( +
+ + + {open && ( +
+
+ + {t.theme?.title ?? "Theme"} + +
+ + {availableThemes.map((theme) => { + const isActive = theme.name === themeName; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3bf693f21..07e931995 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -275,4 +275,9 @@ export const en: Translations = { language: { switchTo: "Switch to Chinese", }, + + theme: { + title: "Theme", + switchTheme: "Switch theme", + }, }; diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 34813c68f..55f5cffc4 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -287,4 +287,10 @@ export interface Translations { language: { switchTo: string; }; + + // ── Theme switcher ── + theme: { + title: string; + switchTheme: string; + }; } diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 18cb3ee38..869ec9ed9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -275,4 +275,9 @@ export const zh: Translations = { language: { switchTo: "切换到英文", }, + + theme: { + title: "主题", + switchTheme: "切换主题", + }, }; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e61043993..9121b83cd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -182,6 +182,16 @@ export const api = { }, ); }, + + // Dashboard themes + getThemes: () => + fetchJSON("/api/dashboard/themes"), + setTheme: (name: string) => + fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), }; export interface PlatformStatus { @@ -415,3 +425,10 @@ export interface OAuthPollResponse { error_message?: string | null; expires_at?: number | null; } + +// ── Dashboard theme types ────────────────────────────────────────────── + +export interface ThemeListResponse { + themes: Array<{ name: string; label: string; description: string }>; + active: string; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 3b77464d5..f04290ada 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,11 +3,14 @@ import { BrowserRouter } from "react-router-dom"; import "./index.css"; import App from "./App"; import { I18nProvider } from "./i18n"; +import { ThemeProvider } from "./themes"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx new file mode 100644 index 000000000..cdceb1532 --- /dev/null +++ b/web/src/themes/context.tsx @@ -0,0 +1,169 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { DashboardTheme, ThemeColors, ThemeOverlay } from "./types"; +import { BUILTIN_THEMES, defaultTheme } from "./presets"; +import { api } from "@/lib/api"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Apply a theme's color overrides to `document.documentElement`. */ +function applyColors(colors: ThemeColors) { + const root = document.documentElement; + for (const [key, value] of Object.entries(colors)) { + root.style.setProperty(`--color-${key}`, value); + } +} + +/** Apply overlay overrides (noise + warm-glow). */ +function applyOverlay(overlay: ThemeOverlay | undefined) { + const noiseEl = document.querySelector(".noise-overlay"); + const glowEl = document.querySelector(".warm-glow"); + + if (noiseEl) { + noiseEl.style.opacity = String(overlay?.noiseOpacity ?? 0.10); + noiseEl.style.mixBlendMode = overlay?.noiseBlendMode ?? "color-dodge"; + } + if (glowEl) { + glowEl.style.opacity = String(overlay?.warmGlowOpacity ?? 0.22); + if (overlay?.warmGlowColor) { + glowEl.style.background = `radial-gradient(ellipse at 0% 0%, ${overlay.warmGlowColor} 0%, rgba(0,0,0,0) 60%)`; + } + } +} + +/** Remove all inline overrides — reverts to stylesheet defaults. */ +function clearOverrides() { + const root = document.documentElement; + // Clear color overrides + for (const key of Object.keys(defaultTheme.colors)) { + root.style.removeProperty(`--color-${key}`); + } + // Clear overlay overrides + const noiseEl = document.querySelector(".noise-overlay"); + const glowEl = document.querySelector(".warm-glow"); + if (noiseEl) { + noiseEl.style.opacity = ""; + noiseEl.style.mixBlendMode = ""; + } + if (glowEl) { + glowEl.style.opacity = ""; + glowEl.style.background = ""; + } +} + +function applyTheme(theme: DashboardTheme) { + if (theme.name === "default") { + clearOverrides(); + } else { + applyColors(theme.colors); + applyOverlay(theme.overlay); + } +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface ThemeContextValue { + /** Currently active theme name. */ + themeName: string; + /** Currently active theme object. */ + theme: DashboardTheme; + /** Available theme names (built-in + any server-provided custom themes). */ + availableThemes: Array<{ name: string; label: string; description: string }>; + /** Switch theme — applies CSS immediately and persists to config.yaml. */ + setTheme: (name: string) => void; + /** True while initial theme is loading from server. */ + loading: boolean; +} + +const ThemeContext = createContext({ + themeName: "default", + theme: defaultTheme, + availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({ + name: t.name, + label: t.label, + description: t.description, + })), + setTheme: () => {}, + loading: true, +}); + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themeName, setThemeName] = useState("default"); + const [availableThemes, setAvailableThemes] = useState( + Object.values(BUILTIN_THEMES).map((t) => ({ + name: t.name, + label: t.label, + description: t.description, + })), + ); + const [loading, setLoading] = useState(true); + + // Fetch active theme + available list from server on mount. + useEffect(() => { + api + .getThemes() + .then((resp) => { + if (resp.themes?.length) { + setAvailableThemes(resp.themes); + } + if (resp.active && resp.active !== "default") { + setThemeName(resp.active); + const t = BUILTIN_THEMES[resp.active]; + if (t) applyTheme(t); + } + }) + .catch(() => { + // Server might not support theme API yet — stay on default. + }) + .finally(() => setLoading(false)); + }, []); + + const resolvedTheme = BUILTIN_THEMES[themeName] ?? defaultTheme; + + const setTheme = useCallback( + (name: string) => { + const t = BUILTIN_THEMES[name] ?? defaultTheme; + setThemeName(t.name); + applyTheme(t); + // Persist to config.yaml — fire and forget. + api.setTheme(t.name).catch(() => {}); + }, + [], + ); + + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/web/src/themes/index.ts b/web/src/themes/index.ts new file mode 100644 index 000000000..2c3509e8e --- /dev/null +++ b/web/src/themes/index.ts @@ -0,0 +1,3 @@ +export { ThemeProvider, useTheme } from "./context"; +export { BUILTIN_THEMES } from "./presets"; +export type { DashboardTheme, ThemeColors, ThemeOverlay, ThemeListResponse } from "./types"; diff --git a/web/src/themes/presets.ts b/web/src/themes/presets.ts new file mode 100644 index 000000000..65fcd4655 --- /dev/null +++ b/web/src/themes/presets.ts @@ -0,0 +1,229 @@ +import type { DashboardTheme } from "./types"; + +/** + * Built-in dashboard themes. + * + * The "default" theme matches the current index.css @theme values exactly, + * so applying it is a no-op (CSS vars stay at their stylesheet defaults). + * Other themes override only what they change. + */ + +export const defaultTheme: DashboardTheme = { + name: "default", + label: "Hermes Teal", + description: "Classic dark teal — the canonical Hermes look", + colors: { + background: "#041C1C", + foreground: "#ffe6cb", + card: "#062424", + "card-foreground": "#ffe6cb", + primary: "#ffe6cb", + "primary-foreground": "#041C1C", + secondary: "#0a2e2e", + "secondary-foreground": "#ffe6cb", + muted: "#083030", + "muted-foreground": "#8aaa9a", + accent: "#0c3838", + "accent-foreground": "#ffe6cb", + destructive: "#fb2c36", + "destructive-foreground": "#fff", + success: "#4ade80", + warning: "#ffbd38", + border: "color-mix(in srgb, #ffe6cb 15%, transparent)", + input: "color-mix(in srgb, #ffe6cb 15%, transparent)", + ring: "#ffe6cb", + popover: "#062424", + "popover-foreground": "#ffe6cb", + }, + overlay: { + noiseOpacity: 0.10, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.22, + warmGlowColor: "rgba(255,189,56,0.35)", + }, +}; + +export const midnightTheme: DashboardTheme = { + name: "midnight", + label: "Midnight", + description: "Deep blue-violet with cool accents", + colors: { + background: "#0a0a1a", + foreground: "#e0e0f0", + card: "#10102a", + "card-foreground": "#e0e0f0", + primary: "#a78bfa", + "primary-foreground": "#0a0a1a", + secondary: "#151530", + "secondary-foreground": "#e0e0f0", + muted: "#1a1a3a", + "muted-foreground": "#8888bb", + accent: "#1e1e44", + "accent-foreground": "#e0e0f0", + destructive: "#f43f5e", + "destructive-foreground": "#fff", + success: "#34d399", + warning: "#fbbf24", + border: "color-mix(in srgb, #a78bfa 15%, transparent)", + input: "color-mix(in srgb, #a78bfa 15%, transparent)", + ring: "#a78bfa", + popover: "#10102a", + "popover-foreground": "#e0e0f0", + }, + overlay: { + noiseOpacity: 0.08, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.15, + warmGlowColor: "rgba(120,80,220,0.3)", + }, +}; + +export const emberTheme: DashboardTheme = { + name: "ember", + label: "Ember", + description: "Warm crimson and bronze — forge vibes", + colors: { + background: "#1a0a0a", + foreground: "#fde8d0", + card: "#241010", + "card-foreground": "#fde8d0", + primary: "#f97316", + "primary-foreground": "#1a0a0a", + secondary: "#2a1515", + "secondary-foreground": "#fde8d0", + muted: "#301818", + "muted-foreground": "#b08878", + accent: "#381e1e", + "accent-foreground": "#fde8d0", + destructive: "#ef4444", + "destructive-foreground": "#fff", + success: "#4ade80", + warning: "#fbbf24", + border: "color-mix(in srgb, #f97316 15%, transparent)", + input: "color-mix(in srgb, #f97316 15%, transparent)", + ring: "#f97316", + popover: "#241010", + "popover-foreground": "#fde8d0", + }, + overlay: { + noiseOpacity: 0.10, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.25, + warmGlowColor: "rgba(249,115,22,0.3)", + }, +}; + +export const monoTheme: DashboardTheme = { + name: "mono", + label: "Mono", + description: "Clean grayscale — minimal and focused", + colors: { + background: "#111111", + foreground: "#e0e0e0", + card: "#1a1a1a", + "card-foreground": "#e0e0e0", + primary: "#e0e0e0", + "primary-foreground": "#111111", + secondary: "#1e1e1e", + "secondary-foreground": "#e0e0e0", + muted: "#222222", + "muted-foreground": "#888888", + accent: "#2a2a2a", + "accent-foreground": "#e0e0e0", + destructive: "#ef4444", + "destructive-foreground": "#fff", + success: "#4ade80", + warning: "#fbbf24", + border: "color-mix(in srgb, #e0e0e0 12%, transparent)", + input: "color-mix(in srgb, #e0e0e0 12%, transparent)", + ring: "#e0e0e0", + popover: "#1a1a1a", + "popover-foreground": "#e0e0e0", + }, + overlay: { + noiseOpacity: 0.06, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.0, + warmGlowColor: "rgba(255,255,255,0)", + }, +}; + +export const cyberpunkTheme: DashboardTheme = { + name: "cyberpunk", + label: "Cyberpunk", + description: "Neon green on black — matrix terminal", + colors: { + background: "#050505", + foreground: "#00ff88", + card: "#0a0a0a", + "card-foreground": "#00ff88", + primary: "#00ff88", + "primary-foreground": "#050505", + secondary: "#0e0e0e", + "secondary-foreground": "#00ff88", + muted: "#121212", + "muted-foreground": "#00aa55", + accent: "#161616", + "accent-foreground": "#00ff88", + destructive: "#ff0055", + "destructive-foreground": "#fff", + success: "#00ff88", + warning: "#ffff00", + border: "color-mix(in srgb, #00ff88 12%, transparent)", + input: "color-mix(in srgb, #00ff88 12%, transparent)", + ring: "#00ff88", + popover: "#0a0a0a", + "popover-foreground": "#00ff88", + }, + overlay: { + noiseOpacity: 0.12, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.10, + warmGlowColor: "rgba(0,255,136,0.15)", + }, +}; + +export const roseTheme: DashboardTheme = { + name: "rose", + label: "Rosé", + description: "Soft pink and warm ivory — easy on the eyes", + colors: { + background: "#1a1015", + foreground: "#f5e6e0", + card: "#221820", + "card-foreground": "#f5e6e0", + primary: "#f9a8d4", + "primary-foreground": "#1a1015", + secondary: "#281e28", + "secondary-foreground": "#f5e6e0", + muted: "#2e2230", + "muted-foreground": "#b08898", + accent: "#352838", + "accent-foreground": "#f5e6e0", + destructive: "#fb2c36", + "destructive-foreground": "#fff", + success: "#4ade80", + warning: "#fbbf24", + border: "color-mix(in srgb, #f9a8d4 14%, transparent)", + input: "color-mix(in srgb, #f9a8d4 14%, transparent)", + ring: "#f9a8d4", + popover: "#221820", + "popover-foreground": "#f5e6e0", + }, + overlay: { + noiseOpacity: 0.08, + noiseBlendMode: "color-dodge", + warmGlowOpacity: 0.18, + warmGlowColor: "rgba(249,168,212,0.2)", + }, +}; + +/** All built-in themes, keyed by name. */ +export const BUILTIN_THEMES: Record = { + default: defaultTheme, + midnight: midnightTheme, + ember: emberTheme, + mono: monoTheme, + cyberpunk: cyberpunkTheme, + rose: roseTheme, +}; diff --git a/web/src/themes/types.ts b/web/src/themes/types.ts new file mode 100644 index 000000000..b6cd371a5 --- /dev/null +++ b/web/src/themes/types.ts @@ -0,0 +1,44 @@ +/** Dashboard theme definition. Maps 1:1 to CSS custom properties in index.css. */ +export interface ThemeColors { + background: string; + foreground: string; + card: string; + "card-foreground": string; + primary: string; + "primary-foreground": string; + secondary: string; + "secondary-foreground": string; + muted: string; + "muted-foreground": string; + accent: string; + "accent-foreground": string; + destructive: string; + "destructive-foreground": string; + success: string; + warning: string; + border: string; + input: string; + ring: string; + popover: string; + "popover-foreground": string; +} + +export interface ThemeOverlay { + noiseOpacity?: number; + noiseBlendMode?: string; + warmGlowOpacity?: number; + warmGlowColor?: string; +} + +export interface DashboardTheme { + name: string; + label: string; + description: string; + colors: ThemeColors; + overlay?: ThemeOverlay; +} + +export interface ThemeListResponse { + themes: Array<{ name: string; label: string; description: string }>; + active: string; +} From 333cb8251b4202e8cdcf3a296ff3850a5591fc94 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:44:56 -0700 Subject: [PATCH 296/849] fix: improve interrupt responsiveness during concurrent tool execution and follow-up turns (#10935) Three targeted fixes for the 'agent stuck on terminal command' report: 1. **Concurrent tool wait loop now checks interrupts** (run_agent.py) The sequential path checked _interrupt_requested before each tool call, but the concurrent path's wait loop just blocked with 30s timeouts. Now polls every 5s and cancels pending futures on interrupt, giving already-running tools 3s to notice the per-thread interrupt signal. 2. **Cancelled concurrent tools get proper interrupt messages** (run_agent.py) When a concurrent tool is cancelled or didn't return a result due to interrupt, the tool result message says 'skipped due to user interrupt' instead of a generic error. 3. **Typing indicator fires before follow-up turn** (gateway/run.py) After an interrupt is acknowledged and the pending message dequeued, the gateway now sends a typing indicator before starting the recursive _run_agent call. This gives the user immediate visual feedback that the system is processing their new message (closing the perceived 'dead air' gap between the interrupt ack and the response). Reported by @_SushantSays. --- gateway/run.py | 13 ++ run_agent.py | 55 ++++++-- tests/run_agent/test_concurrent_interrupt.py | 139 +++++++++++++++++++ 3 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 tests/run_agent/test_concurrent_interrupt.py diff --git a/gateway/run.py b/gateway/run.py index 13f4cb647..9c2b5b1db 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9443,6 +9443,19 @@ class GatewayRunner: return result next_message_id = getattr(pending_event, "message_id", None) + # Restart typing indicator so the user sees activity while + # the follow-up turn runs. The outer _process_message_background + # typing task is still alive but may be stale. + _followup_adapter = self.adapters.get(source.platform) + if _followup_adapter: + try: + await _followup_adapter.send_typing( + source.chat_id, + metadata=_status_thread_metadata, + ) + except Exception: + pass + return await self._run_agent( message=next_message, context_prompt=context_prompt, diff --git a/run_agent.py b/run_agent.py index 944217e6b..d6dc9a024 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7549,24 +7549,50 @@ class AIAgent: # Wait for all to complete with periodic heartbeats so the # gateway's inactivity monitor doesn't kill us during long - # concurrent tool batches. + # concurrent tool batches. Also check for user interrupts + # so we don't block indefinitely when the user sends /stop + # or a new message during concurrent tool execution. _conc_start = time.time() + _interrupt_logged = False while True: done, not_done = concurrent.futures.wait( - futures, timeout=30.0, + futures, timeout=5.0, ) if not not_done: break + + # Check for interrupt — the per-thread interrupt signal + # already causes individual tools (terminal, execute_code) + # to abort, but tools without interrupt checks (web_search, + # read_file) will run to completion. Cancel any futures + # that haven't started yet so we don't block on them. + if self._interrupt_requested: + if not _interrupt_logged: + _interrupt_logged = True + self._vprint( + f"{self.log_prefix}⚡ Interrupt: cancelling " + f"{len(not_done)} pending concurrent tool(s)", + force=True, + ) + for f in not_done: + f.cancel() + # Give already-running tools a moment to notice the + # per-thread interrupt signal and exit gracefully. + concurrent.futures.wait(not_done, timeout=3.0) + break + _conc_elapsed = int(time.time() - _conc_start) - _still_running = [ - parsed_calls[futures.index(f)][1] - for f in not_done - if f in futures - ] - self._touch_activity( - f"concurrent tools running ({_conc_elapsed}s, " - f"{len(not_done)} remaining: {', '.join(_still_running[:3])})" - ) + # Heartbeat every ~30s (6 × 5s poll intervals) + if _conc_elapsed > 0 and _conc_elapsed % 30 < 6: + _still_running = [ + parsed_calls[futures.index(f)][1] + for f in not_done + if f in futures + ] + self._touch_activity( + f"concurrent tools running ({_conc_elapsed}s, " + f"{len(not_done)} remaining: {', '.join(_still_running[:3])})" + ) finally: if spinner: # Build a summary message for the spinner stop @@ -7578,8 +7604,11 @@ class AIAgent: for i, (tc, name, args) in enumerate(parsed_calls): r = results[i] if r is None: - # Shouldn't happen, but safety fallback - function_result = f"Error executing tool '{name}': thread did not return a result" + # Tool was cancelled (interrupt) or thread didn't return + if self._interrupt_requested: + function_result = f"[Tool execution cancelled — {name} was skipped due to user interrupt]" + else: + function_result = f"Error executing tool '{name}': thread did not return a result" tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error = r diff --git a/tests/run_agent/test_concurrent_interrupt.py b/tests/run_agent/test_concurrent_interrupt.py new file mode 100644 index 000000000..fdeb8dd69 --- /dev/null +++ b/tests/run_agent/test_concurrent_interrupt.py @@ -0,0 +1,139 @@ +"""Tests for interrupt handling in concurrent tool execution.""" + +import concurrent.futures +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_hermes(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir(exist_ok=True) + + +def _make_agent(monkeypatch): + """Create a minimal AIAgent-like object with just the methods under test.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "") + monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "") + # Avoid full AIAgent init — just import the class and build a stub + import run_agent as _ra + + class _Stub: + _interrupt_requested = False + log_prefix = "" + quiet_mode = True + verbose_logging = False + log_prefix_chars = 200 + _checkpoint_mgr = MagicMock(enabled=False) + _subdirectory_hints = MagicMock() + tool_progress_callback = None + tool_start_callback = None + tool_complete_callback = None + _todo_store = MagicMock() + _session_db = None + valid_tool_names = set() + _turns_since_memory = 0 + _iters_since_skill = 0 + _current_tool = None + _last_activity = 0 + _print_fn = print + + def _touch_activity(self, desc): + self._last_activity = time.time() + + def _vprint(self, msg, force=False): + pass + + def _safe_print(self, msg): + pass + + def _should_emit_quiet_tool_messages(self): + return False + + def _should_start_quiet_spinner(self): + return False + + def _has_stream_consumers(self): + return False + + stub = _Stub() + # Bind the real methods + stub._execute_tool_calls_concurrent = _ra.AIAgent._execute_tool_calls_concurrent.__get__(stub) + stub._invoke_tool = MagicMock(side_effect=lambda *a, **kw: '{"ok": true}') + return stub + + +class _FakeToolCall: + def __init__(self, name, args="{}", call_id="tc_1"): + self.function = MagicMock(name=name, arguments=args) + self.function.name = name + self.id = call_id + + +class _FakeAssistantMsg: + def __init__(self, tool_calls): + self.tool_calls = tool_calls + + +def test_concurrent_interrupt_cancels_pending(monkeypatch): + """When _interrupt_requested is set during concurrent execution, + the wait loop should exit early and cancelled tools get interrupt messages.""" + agent = _make_agent(monkeypatch) + + # Create a tool that blocks until interrupted + barrier = threading.Event() + + original_invoke = agent._invoke_tool + + def slow_tool(name, args, task_id, call_id=None): + if name == "slow_one": + # Block until the test sets the interrupt + barrier.wait(timeout=10) + return '{"slow": true}' + return '{"fast": true}' + + agent._invoke_tool = MagicMock(side_effect=slow_tool) + + tc1 = _FakeToolCall("fast_one", call_id="tc_fast") + tc2 = _FakeToolCall("slow_one", call_id="tc_slow") + msg = _FakeAssistantMsg([tc1, tc2]) + messages = [] + + def _set_interrupt_after_delay(): + time.sleep(0.3) + agent._interrupt_requested = True + barrier.set() # unblock the slow tool + + t = threading.Thread(target=_set_interrupt_after_delay) + t.start() + + agent._execute_tool_calls_concurrent(msg, messages, "test_task") + t.join() + + # Both tools should have results in messages + assert len(messages) == 2 + # The interrupt was detected + assert agent._interrupt_requested is True + + +def test_concurrent_preflight_interrupt_skips_all(monkeypatch): + """When _interrupt_requested is already set before concurrent execution, + all tools are skipped with cancellation messages.""" + agent = _make_agent(monkeypatch) + agent._interrupt_requested = True + + tc1 = _FakeToolCall("tool_a", call_id="tc_a") + tc2 = _FakeToolCall("tool_b", call_id="tc_b") + msg = _FakeAssistantMsg([tc1, tc2]) + messages = [] + + agent._execute_tool_calls_concurrent(msg, messages, "test_task") + + assert len(messages) == 2 + assert "skipped due to user interrupt" in messages[0]["content"] + assert "skipped due to user interrupt" in messages[1]["content"] + # _invoke_tool should never have been called + agent._invoke_tool.assert_not_called() From c928ebb1b1a1c22cb4fba0069d645697ebd591d5 Mon Sep 17 00:00:00 2001 From: Markus Corazzione Date: Mon, 13 Apr 2026 12:58:55 -0300 Subject: [PATCH 297/849] retry transient telegram send failures --- tests/tools/test_send_message_tool.py | 17 +++++++++ tools/send_message_tool.py | 50 +++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 8b4241300..729a1fdec 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -816,6 +816,23 @@ class TestSendTelegramHtmlDetection: second_call = bot.send_message.await_args_list[1].kwargs assert second_call["parse_mode"] is None + def test_transient_bad_gateway_retries_text_send(self, monkeypatch): + bot = self._make_bot() + bot.send_message = AsyncMock( + side_effect=[ + Exception("502 Bad Gateway"), + SimpleNamespace(message_id=2), + ] + ) + _install_telegram_mock(monkeypatch, bot) + + with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock: + result = asyncio.run(_send_telegram("tok", "123", "hello")) + + assert result["success"] is True + assert bot.send_message.await_count == 2 + sleep_mock.assert_awaited_once() + # --------------------------------------------------------------------------- # Tests for Discord thread_id support diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 782155c83..37a16f78c 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -5,6 +5,7 @@ Sends a message to a user or channel on any connected messaging platform human-friendly channel names to IDs. Works in both CLI and gateway contexts. """ +import asyncio import json import logging import os @@ -48,6 +49,49 @@ def _error(message: str) -> dict: return {"error": _sanitize_error_text(message)} +def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None: + retry_after = getattr(exc, "retry_after", None) + if retry_after is not None: + try: + return max(float(retry_after), 0.0) + except (TypeError, ValueError): + return 1.0 + + text = str(exc).lower() + if "timed out" in text or "timeout" in text: + return None + if ( + "bad gateway" in text + or "502" in text + or "too many requests" in text + or "429" in text + or "service unavailable" in text + or "503" in text + or "gateway timeout" in text + or "504" in text + ): + return float(2 ** attempt) + return None + + +async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs): + for attempt in range(attempts): + try: + return await bot.send_message(**kwargs) + except Exception as exc: + delay = _telegram_retry_delay(exc, attempt) + if delay is None or attempt >= attempts - 1: + raise + logger.warning( + "Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s", + attempt + 1, + attempts, + delay, + _sanitize_error_text(exc), + ) + await asyncio.sleep(delay) + + SEND_MESSAGE_SCHEMA = { "name": "send_message", "description": ( @@ -530,7 +574,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No if formatted.strip(): try: - last_msg = await bot.send_message( + last_msg = await _send_telegram_message_with_retry( + bot, chat_id=int_chat_id, text=formatted, parse_mode=send_parse_mode, **thread_kwargs ) @@ -550,7 +595,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No plain = message else: plain = message - last_msg = await bot.send_message( + last_msg = await _send_telegram_message_with_retry( + bot, chat_id=int_chat_id, text=plain, parse_mode=None, **thread_kwargs ) From f05590796e55e377bc045003083334154d32ee22 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Thu, 16 Apr 2026 03:03:43 -0700 Subject: [PATCH 298/849] fix(telegram): increase cold-boot retry budget and cap backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump connect retry attempts from 3 to 8 and cap exponential backoff at 15 seconds. Old budget: 3 attempts, 1+2+4=7s total — insufficient for cold boot on slow networks or embedded devices. New budget: 8 attempts, 1+2+4+8+15+15+15=~60s total. Inspired by PR #5770 by @Bartok9 (re-implemented against current main since original was 913 commits stale with conflicts). --- gateway/platforms/telegram.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 1bda152f5..d5578961c 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -665,14 +665,14 @@ class TelegramAdapter(BasePlatformAdapter): from telegram.error import NetworkError, TimedOut except ImportError: NetworkError = TimedOut = OSError # type: ignore[misc,assignment] - _max_connect = 3 + _max_connect = 8 for _attempt in range(_max_connect): try: await self._app.initialize() break except (NetworkError, TimedOut, OSError) as init_err: if _attempt < _max_connect - 1: - wait = 2 ** _attempt + wait = min(2 ** _attempt, 15) logger.warning( "[%s] Connect attempt %d/%d failed: %s — retrying in %ds", self.name, _attempt + 1, _max_connect, init_err, wait, From e66b3733512b692f4c024296d3e3017739a661ae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 03:50:49 -0700 Subject: [PATCH 299/849] fix: word-wrap spinner, interruptable agent join, and delegate_task interrupt (#10940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stop /model from silently rerouting direct providers to OpenRouter (#10300) detect_provider_for_model() silently remapped models to OpenRouter when the direct provider's credentials weren't found via env vars. Three bugs: 1. Credential check only looked at env vars from PROVIDER_REGISTRY, missing credential pool entries, auth store, and OAuth tokens 2. When env var check failed, silently returned ('openrouter', slug) instead of the direct provider the model actually belongs to 3. Users with valid credentials via non-env-var mechanisms (pool, OAuth, Claude Code tokens) got silently rerouted Fix: - Expand credential check to also query credential pool and auth store - Always return the direct provider match regardless of credential status -- let client init handle missing creds with a clear error rather than silently routing through the wrong provider Same philosophy as the provider-required fix: don't guess, don't silently reroute, error clearly when something is missing. Closes #10300 * fix: word-wrap spinner, interruptable agent join, and delegate_task interrupt Three fixes: 1. Spinner widget clips long tool commands — prompt_toolkit Window had height=1 and wrap_lines=False. Now uses wrap_lines=True with dynamic height from text length / terminal width. Long commands wrap naturally. 2. agent_thread.join() blocked forever after interrupt — if the agent thread took time to clean up, the process_loop thread froze. Now polls with 0.2s timeout on the interrupt path, checking _should_exit so double Ctrl+C breaks out immediately. 3. Root cause of 5-hour CLI hang: delegate_task() used as_completed() with no interrupt check. When subagent children got stuck, the parent blocked forever inside the ThreadPoolExecutor. Now polls with wait(timeout=0.5) and checks parent_agent._interrupt_requested each iteration. Stuck children are reported as interrupted, and the parent returns immediately. --- cli.py | 41 +++++++++++++++++++++++-- tools/delegate_tool.py | 70 +++++++++++++++++++++++++++++++++--------- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/cli.py b/cli.py index 3a3e8108f..b9b111725 100644 --- a/cli.py +++ b/cli.py @@ -2013,7 +2013,17 @@ class HermesCLI: """Return the visible height for the spinner/status text line above the status bar.""" if not getattr(self, "_spinner_text", ""): return 0 - return 0 if self._use_minimal_tui_chrome(width=width) else 1 + if self._use_minimal_tui_chrome(width=width): + return 0 + # Compute how many lines the spinner text needs when wrapped. + # The rendered text is " {emoji} {label} ({elapsed})" — about + # len(_spinner_text) + 16 chars for indent + timer suffix. + width = width or self._get_tui_terminal_width() + if width and width > 10: + import math + text_len = len(self._spinner_text) + 16 # indent + timer + return max(1, math.ceil(text_len / width)) + return 1 def _get_voice_status_fragments(self, width: Optional[int] = None): """Return the voice status bar fragments for the interactive TUI.""" @@ -7750,7 +7760,33 @@ class HermesCLI: # Fallback for non-interactive mode (e.g., single-query) agent_thread.join(0.1) - agent_thread.join() # Ensure agent thread completes + # Wait for the agent thread to finish. After an interrupt the + # agent may take a few seconds to clean up (kill subprocess, persist + # session). Poll instead of a blocking join so the process_loop + # stays responsive — if the user sent another interrupt or the + # agent gets stuck, we can break out instead of freezing forever. + if interrupt_msg is not None: + # Interrupt path: poll briefly, then move on. The agent + # thread is daemon — it dies on process exit regardless. + for _wait_tick in range(50): # 50 * 0.2s = 10s max + agent_thread.join(timeout=0.2) + if not agent_thread.is_alive(): + break + # Check if user fired ANOTHER interrupt (Ctrl+C sets + # _should_exit which process_loop checks on next pass). + if getattr(self, '_should_exit', False): + break + if agent_thread.is_alive(): + logger.warning( + "Agent thread still alive after interrupt " + "(thread %s). Daemon thread will be cleaned up " + "on exit.", + agent_thread.ident, + ) + else: + # Normal completion: agent thread should be done already, + # but guard against edge cases. + agent_thread.join(timeout=30) # Proactively clean up async clients whose event loop is dead. # The agent thread may have created AsyncOpenAI clients bound @@ -9043,6 +9079,7 @@ class HermesCLI: spinner_widget = Window( content=FormattedTextControl(get_spinner_text), height=get_spinner_height, + wrap_lines=True, ) spacer = Window( diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 73ba81272..87218b1ba 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -750,21 +750,61 @@ def delegate_task( ) futures[future] = i - for future in as_completed(futures): - try: - entry = future.result() - except Exception as exc: - idx = futures[future] - entry = { - "task_index": idx, - "status": "error", - "summary": None, - "error": str(exc), - "api_calls": 0, - "duration_seconds": 0, - } - results.append(entry) - completed_count += 1 + # Poll futures with interrupt checking. as_completed() blocks + # until ALL futures finish — if a child agent gets stuck, + # the parent blocks forever even after interrupt propagation. + # Instead, use wait() with a short timeout so we can bail + # when the parent is interrupted. + pending = set(futures.keys()) + while pending: + if getattr(parent_agent, "_interrupt_requested", False) is True: + # Parent interrupted — collect whatever finished and + # abandon the rest. Children already received the + # interrupt signal; we just can't wait forever. + for f in pending: + idx = futures[f] + if f.done(): + try: + entry = f.result() + except Exception as exc: + entry = { + "task_index": idx, + "status": "error", + "summary": None, + "error": str(exc), + "api_calls": 0, + "duration_seconds": 0, + } + else: + entry = { + "task_index": idx, + "status": "interrupted", + "summary": None, + "error": "Parent agent interrupted — child did not finish in time", + "api_calls": 0, + "duration_seconds": 0, + } + results.append(entry) + completed_count += 1 + break + + from concurrent.futures import wait as _cf_wait, FIRST_COMPLETED + done, pending = _cf_wait(pending, timeout=0.5, return_when=FIRST_COMPLETED) + for future in done: + try: + entry = future.result() + except Exception as exc: + idx = futures[future] + entry = { + "task_index": idx, + "status": "error", + "summary": None, + "error": str(exc), + "api_calls": 0, + "duration_seconds": 0, + } + results.append(entry) + completed_count += 1 # Print per-task completion line above the spinner idx = entry["task_index"] From e07dbde582e6c80f80eb0d3040add8331832a87b Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 16 Apr 2026 03:58:50 -0700 Subject: [PATCH 300/849] Revert "fix: enable TCP keepalives to detect dead provider connections (#10324)" This reverts commit 64fee35dc00257bd8c8069961b9cdf30f0e14d7c. --- run_agent.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/run_agent.py b/run_agent.py index d6dc9a024..bb1c8b899 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4366,29 +4366,6 @@ class AIAgent: self._client_log_context(), ) return client - # Inject TCP keepalives to detect dead connections faster (#10324). - # Without keepalives, a provider that drops mid-stream leaves the - # socket in CLOSE-WAIT and epoll_wait may never fire, causing the - # agent to hang indefinitely. Keepalive probes detect the dead - # peer within ~60s (30s idle + 3×10s probes). - if "http_client" not in client_kwargs: - try: - import httpx as _httpx - import socket as _socket - _sock_opts = [(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)] - if hasattr(_socket, "TCP_KEEPIDLE"): - # Linux - _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPIDLE, 30)) - _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPINTVL, 10)) - _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPCNT, 3)) - elif hasattr(_socket, "TCP_KEEPALIVE"): - # macOS (uses TCP_KEEPALIVE instead of TCP_KEEPIDLE) - _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30)) - client_kwargs["http_client"] = _httpx.Client( - transport=_httpx.HTTPTransport(socket_options=_sock_opts), - ) - except Exception: - pass # Fall through to default transport if socket opts fail client = OpenAI(**client_kwargs) logger.info( "OpenAI client created (%s, shared=%s) %s", From 23a42635f06e35af8425a58b07486aa6b8ae365b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:07:11 -0700 Subject: [PATCH 301/849] docs: remove nonexistent CAMOFOX_PROFILE_DIR env var references (#10976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camofox automatically maps each userId to a persistent Firefox profile on the server side — no CAMOFOX_PROFILE_DIR env var exists. Our docs incorrectly told users to configure this on the server. Removed the fabricated env var from: - browser docs (:::note block) - config.py DEFAULT_CONFIG comment - test docstring --- hermes_cli/config.py | 3 +-- tests/tools/test_browser_camofox_persistence.py | 4 ++-- website/docs/user-guide/features/browser.md | 6 +----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index eed9d5c3a..a9f55f4c5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -420,8 +420,7 @@ DEFAULT_CONFIG = { "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) "camofox": { # When true, Hermes sends a stable profile-scoped userId to Camofox - # so the server can map it to a persistent browser profile directory. - # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR. + # so the server maps it to a persistent Firefox profile automatically. # When false (default), each session gets a random userId (ephemeral). "managed_persistence": False, }, diff --git a/tests/tools/test_browser_camofox_persistence.py b/tests/tools/test_browser_camofox_persistence.py index c95b640aa..eddd36f00 100644 --- a/tests/tools/test_browser_camofox_persistence.py +++ b/tests/tools/test_browser_camofox_persistence.py @@ -1,8 +1,8 @@ """Persistence tests for the Camofox browser backend. Tests that managed persistence uses stable identity while default mode -uses random identity. The actual browser profile persistence is handled -by the Camofox server (when CAMOFOX_PROFILE_DIR is set). +uses random identity. Camofox automatically maps each userId to a +dedicated persistent Firefox profile on the server side. """ import json diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index bf7c61689..016f29f7c 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -116,11 +116,7 @@ browser: managed_persistence: true ``` -When enabled, Hermes sends a stable profile-scoped identity to Camofox. The Camofox server maps this identity to a persistent browser profile directory, so cookies, logins, and localStorage survive across restarts. Different Hermes profiles get different browser profiles (profile isolation). - -:::note -The Camofox server must also be configured with `CAMOFOX_PROFILE_DIR` on the server side for persistence to work. -::: +When enabled, Hermes sends a stable profile-scoped `userId` to Camofox. The Camofox server automatically maps each `userId` to a dedicated persistent Firefox profile, so cookies, logins, and localStorage survive across restarts. Different Hermes profiles get different browser profiles (profile isolation). #### VNC live view From 01214a7f73eef4223a70e9a6359cbaa818608f52 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 16 Apr 2026 03:10:28 -0700 Subject: [PATCH 302/849] =?UTF-8?q?feat:=20dashboard=20plugin=20system=20?= =?UTF-8?q?=E2=80=94=20extend=20the=20web=20UI=20with=20custom=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a plugin system that lets plugins add new tabs to the dashboard. Plugins live in ~/.hermes/plugins//dashboard/ alongside any existing CLI/gateway plugin code. Plugin structure: plugins//dashboard/ manifest.json # name, label, icon, tab config, entry point dist/index.js # pre-built JS bundle (IIFE, uses SDK globals) plugin_api.py # optional FastAPI router mounted at /api/plugins// Backend (hermes_cli/web_server.py): - Plugin discovery: scans plugins/*/dashboard/manifest.json from user, bundled, and project plugin directories - GET /api/dashboard/plugins — returns discovered plugin manifests - GET /api/dashboard/plugins/rescan — force re-discovery - GET /dashboard-plugins// — serves plugin static assets with path traversal protection - Optional API route mounting: imports plugin_api.py and mounts its router under /api/plugins// - Plugin API routes bypass session token auth (localhost-only) Frontend (web/src/plugins/): - Plugin SDK exposed on window.__HERMES_PLUGIN_SDK__ — provides React, hooks, UI components (Card, Badge, Button, etc.), API client, fetchJSON, theme/i18n hooks, and utilities - Plugin registry on window.__HERMES_PLUGINS__.register(name, Component) - usePlugins() hook: fetches manifests, loads JS/CSS, resolves components - App.tsx dynamically adds nav items and routes for discovered plugins - Icon resolution via static map of 20 common Lucide icons (no tree- shaking penalty — bundle only +5KB over baseline) Example plugin (plugins/example-dashboard/): - Demonstrates SDK usage: Card components, backend API call, SDK reference - Backend route: GET /api/plugins/example/hello Tested: plugin discovery, static serving, API routes, path traversal blocking, unknown plugin 404, bundle size (400KB vs 394KB baseline). --- hermes_cli/web_server.py | 166 +++++++++++++++++- .../example-dashboard/dashboard/dist/index.js | 94 ++++++++++ .../example-dashboard/dashboard/manifest.json | 13 ++ .../example-dashboard/dashboard/plugin_api.py | 14 ++ web/src/App.tsx | 114 ++++++++++-- web/src/lib/api.ts | 23 ++- web/src/main.tsx | 5 + web/src/plugins/index.ts | 3 + web/src/plugins/registry.ts | 131 ++++++++++++++ web/src/plugins/types.ts | 22 +++ web/src/plugins/usePlugins.ts | 90 ++++++++++ 11 files changed, 660 insertions(+), 15 deletions(-) create mode 100644 plugins/example-dashboard/dashboard/dist/index.js create mode 100644 plugins/example-dashboard/dashboard/manifest.json create mode 100644 plugins/example-dashboard/dashboard/plugin_api.py create mode 100644 web/src/plugins/index.ts create mode 100644 web/src/plugins/registry.ts create mode 100644 web/src/plugins/types.ts create mode 100644 web/src/plugins/usePlugins.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4d39d379b..9175c41e2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -11,6 +11,7 @@ Usage: import asyncio import hmac +import importlib.util import json import logging import os @@ -97,6 +98,8 @@ _PUBLIC_API_PATHS: frozenset = frozenset({ "/api/config/schema", "/api/model/info", "/api/dashboard/themes", + "/api/dashboard/plugins", + "/api/dashboard/plugins/rescan", }) @@ -115,7 +118,7 @@ def _require_token(request: Request) -> None: async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" path = request.url.path - if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: + if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"): auth = request.headers.get("authorization", "") expected = f"Bearer {_SESSION_TOKEN}" if not hmac.compare_digest(auth.encode(), expected.encode()): @@ -2145,6 +2148,167 @@ async def set_dashboard_theme(body: ThemeSetBody): return {"ok": True, "theme": body.name} +# --------------------------------------------------------------------------- +# Dashboard plugin system +# --------------------------------------------------------------------------- + +def _discover_dashboard_plugins() -> list: + """Scan plugins/*/dashboard/manifest.json for dashboard extensions. + + Checks three plugin sources (same as hermes_cli.plugins): + 1. User plugins: ~/.hermes/plugins//dashboard/manifest.json + 2. Bundled plugins: /plugins//dashboard/manifest.json (memory/, etc.) + 3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS) + """ + plugins = [] + seen_names: set = set() + + search_dirs = [ + (get_hermes_home() / "plugins", "user"), + (PROJECT_ROOT / "plugins" / "memory", "bundled"), + (PROJECT_ROOT / "plugins", "bundled"), + ] + if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): + search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project")) + + for plugins_root, source in search_dirs: + if not plugins_root.is_dir(): + continue + for child in sorted(plugins_root.iterdir()): + if not child.is_dir(): + continue + manifest_file = child / "dashboard" / "manifest.json" + if not manifest_file.exists(): + continue + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + name = data.get("name", child.name) + if name in seen_names: + continue + seen_names.add(name) + plugins.append({ + "name": name, + "label": data.get("label", name), + "description": data.get("description", ""), + "icon": data.get("icon", "Puzzle"), + "version": data.get("version", "0.0.0"), + "tab": data.get("tab", {"path": f"/{name}", "position": "end"}), + "entry": data.get("entry", "dist/index.js"), + "css": data.get("css"), + "has_api": bool(data.get("api")), + "source": source, + "_dir": str(child / "dashboard"), + "_api_file": data.get("api"), + }) + except Exception as exc: + _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) + continue + return plugins + + +# Cache discovered plugins per-process (refresh on explicit re-scan). +_dashboard_plugins_cache: Optional[list] = None + + +def _get_dashboard_plugins(force_rescan: bool = False) -> list: + global _dashboard_plugins_cache + if _dashboard_plugins_cache is None or force_rescan: + _dashboard_plugins_cache = _discover_dashboard_plugins() + return _dashboard_plugins_cache + + +@app.get("/api/dashboard/plugins") +async def get_dashboard_plugins(): + """Return discovered dashboard plugins.""" + plugins = _get_dashboard_plugins() + # Strip internal fields before sending to frontend. + return [ + {k: v for k, v in p.items() if not k.startswith("_")} + for p in plugins + ] + + +@app.get("/api/dashboard/plugins/rescan") +async def rescan_dashboard_plugins(): + """Force re-scan of dashboard plugins.""" + plugins = _get_dashboard_plugins(force_rescan=True) + return {"ok": True, "count": len(plugins)} + + +@app.get("/dashboard-plugins/{plugin_name}/{file_path:path}") +async def serve_plugin_asset(plugin_name: str, file_path: str): + """Serve static assets from a dashboard plugin directory. + + Only serves files from the plugin's ``dashboard/`` subdirectory. + Path traversal is blocked by checking ``resolve().is_relative_to()``. + """ + plugins = _get_dashboard_plugins() + plugin = next((p for p in plugins if p["name"] == plugin_name), None) + if not plugin: + raise HTTPException(status_code=404, detail="Plugin not found") + + base = Path(plugin["_dir"]) + target = (base / file_path).resolve() + + if not target.is_relative_to(base.resolve()): + raise HTTPException(status_code=403, detail="Path traversal blocked") + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Guess content type + suffix = target.suffix.lower() + content_types = { + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".html": "text/html", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff2": "font/woff2", + ".woff": "font/woff", + } + media_type = content_types.get(suffix, "application/octet-stream") + return FileResponse(target, media_type=media_type) + + +def _mount_plugin_api_routes(): + """Import and mount backend API routes from plugins that declare them. + + Each plugin's ``api`` field points to a Python file that must expose + a ``router`` (FastAPI APIRouter). Routes are mounted under + ``/api/plugins//``. + """ + for plugin in _get_dashboard_plugins(): + api_file_name = plugin.get("_api_file") + if not api_file_name: + continue + api_path = Path(plugin["_dir"]) / api_file_name + if not api_path.exists(): + _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) + continue + try: + spec = importlib.util.spec_from_file_location( + f"hermes_dashboard_plugin_{plugin['name']}", api_path, + ) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + router = getattr(mod, "router", None) + if router is None: + _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"]) + continue + app.include_router(router, prefix=f"/api/plugins/{plugin['name']}") + _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"]) + except Exception as exc: + _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc) + + +# Mount plugin API routes before the SPA catch-all. +_mount_plugin_api_routes() + mount_spa(app) diff --git a/plugins/example-dashboard/dashboard/dist/index.js b/plugins/example-dashboard/dashboard/dist/index.js new file mode 100644 index 000000000..a54916be4 --- /dev/null +++ b/plugins/example-dashboard/dashboard/dist/index.js @@ -0,0 +1,94 @@ +/** + * Example Dashboard Plugin + * + * Demonstrates how to build a dashboard plugin using the Hermes Plugin SDK. + * No build step needed — this is a plain IIFE that uses globals from the SDK. + */ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + const { React } = SDK; + const { Card, CardHeader, CardTitle, CardContent, Badge, Button } = SDK.components; + const { useState, useEffect } = SDK.hooks; + const { cn } = SDK.utils; + + function ExamplePage() { + const [greeting, setGreeting] = useState(null); + const [loading, setLoading] = useState(false); + + function fetchGreeting() { + setLoading(true); + SDK.fetchJSON("/api/plugins/example/hello") + .then(function (data) { setGreeting(data.message); }) + .catch(function () { setGreeting("(backend not available)"); }) + .finally(function () { setLoading(false); }); + } + + return React.createElement("div", { className: "flex flex-col gap-6" }, + // Header card + React.createElement(Card, null, + React.createElement(CardHeader, null, + React.createElement("div", { className: "flex items-center gap-3" }, + React.createElement(CardTitle, { className: "text-lg" }, "Example Plugin"), + React.createElement(Badge, { variant: "outline" }, "v1.0.0"), + ), + ), + React.createElement(CardContent, { className: "flex flex-col gap-4" }, + React.createElement("p", { className: "text-sm text-muted-foreground" }, + "This is an example dashboard plugin. It demonstrates using the Plugin SDK to build ", + "custom tabs with React components, connect to backend API routes, and integrate with ", + "the existing Hermes UI system.", + ), + React.createElement("div", { className: "flex items-center gap-3" }, + React.createElement(Button, { + onClick: fetchGreeting, + disabled: loading, + className: cn( + "inline-flex items-center gap-2 border border-border bg-background/40 px-4 py-2", + "text-sm font-courier transition-colors hover:bg-foreground/10 cursor-pointer", + ), + }, loading ? "Loading..." : "Call Backend API"), + greeting && React.createElement("span", { + className: "text-sm font-courier text-muted-foreground", + }, greeting), + ), + ), + ), + + // Info card about the SDK + React.createElement(Card, null, + React.createElement(CardHeader, null, + React.createElement(CardTitle, { className: "text-base" }, "Plugin SDK Reference"), + ), + React.createElement(CardContent, null, + React.createElement("div", { className: "grid gap-3 text-sm" }, + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.React"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "React instance — use instead of importing react"), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.hooks"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "useState, useEffect, useCallback, useMemo, useRef, useContext, createContext"), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.components"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "Card, Badge, Button, Input, Label, Select, Separator, Tabs, etc."), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.api"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "Hermes API client — getStatus(), getSessions(), etc."), + ), + React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, + React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.utils"), + React.createElement("span", { className: "text-muted-foreground text-xs" }, "cn(), timeAgo(), isoTimeAgo()"), + ), + ), + ), + ), + ); + } + + // Register this plugin — the dashboard picks it up automatically. + window.__HERMES_PLUGINS__.register("example", ExamplePage); +})(); diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json new file mode 100644 index 000000000..2111bff5e --- /dev/null +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "example", + "label": "Example", + "description": "Example dashboard plugin — demonstrates the plugin SDK", + "icon": "Sparkles", + "version": "1.0.0", + "tab": { + "path": "/example", + "position": "after:skills" + }, + "entry": "dist/index.js", + "api": "plugin_api.py" +} diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py new file mode 100644 index 000000000..20aed76e2 --- /dev/null +++ b/plugins/example-dashboard/dashboard/plugin_api.py @@ -0,0 +1,14 @@ +"""Example dashboard plugin — backend API routes. + +Mounted at /api/plugins/example/ by the dashboard plugin system. +""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + """Simple greeting endpoint to demonstrate plugin API routes.""" + return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/web/src/App.tsx b/web/src/App.tsx index dfadf1067..b07608c31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,11 @@ +import { useMemo } from "react"; import { Routes, Route, NavLink, Navigate } from "react-router-dom"; -import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; +import { + Activity, BarChart3, Clock, FileText, KeyRound, + MessageSquare, Package, Settings, Puzzle, + Sparkles, Terminal, Globe, Database, Shield, + Wrench, Zap, Heart, Star, Code, Eye, +} from "lucide-react"; import StatusPage from "@/pages/StatusPage"; import ConfigPage from "@/pages/ConfigPage"; import EnvPage from "@/pages/EnvPage"; @@ -11,20 +17,90 @@ import SkillsPage from "@/pages/SkillsPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; +import { usePlugins } from "@/plugins"; +import type { RegisteredPlugin } from "@/plugins"; -const NAV_ITEMS = [ - { path: "/", labelKey: "status" as const, icon: Activity }, - { path: "/sessions", labelKey: "sessions" as const, icon: MessageSquare }, - { path: "/analytics", labelKey: "analytics" as const, icon: BarChart3 }, - { path: "/logs", labelKey: "logs" as const, icon: FileText }, - { path: "/cron", labelKey: "cron" as const, icon: Clock }, - { path: "/skills", labelKey: "skills" as const, icon: Package }, - { path: "/config", labelKey: "config" as const, icon: Settings }, - { path: "/env", labelKey: "keys" as const, icon: KeyRound }, -] as const; +// --------------------------------------------------------------------------- +// Built-in nav items +// --------------------------------------------------------------------------- + +interface NavItem { + path: string; + label: string; + labelKey?: string; + icon: React.ComponentType<{ className?: string }>; +} + +const BUILTIN_NAV: NavItem[] = [ + { path: "/", labelKey: "status", label: "Status", icon: Activity }, + { path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare }, + { path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 }, + { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, + { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, + { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, + { path: "/config", labelKey: "config", label: "Config", icon: Settings }, + { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Map of icon names plugins can use. Covers common choices without importing all of lucide. */ +const ICON_MAP: Record> = { + Activity, BarChart3, Clock, FileText, KeyRound, + MessageSquare, Package, Settings, Puzzle, + Sparkles, Terminal, Globe, Database, Shield, + Wrench, Zap, Heart, Star, Code, Eye, +}; + +/** Resolve a Lucide icon name to a component, fallback to Puzzle. */ +function resolveIcon(name: string): React.ComponentType<{ className?: string }> { + return ICON_MAP[name] ?? Puzzle; +} + +/** Insert plugin nav items at the position specified in their manifest. */ +function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] { + const items = [...builtIn]; + + for (const { manifest } of plugins) { + const pluginItem: NavItem = { + path: manifest.tab.path, + label: manifest.label, + icon: resolveIcon(manifest.icon), + }; + + const pos = manifest.tab.position ?? "end"; + if (pos === "end") { + items.push(pluginItem); + } else if (pos.startsWith("after:")) { + const target = "/" + pos.slice(6); + const idx = items.findIndex((i) => i.path === target); + items.splice(idx >= 0 ? idx + 1 : items.length, 0, pluginItem); + } else if (pos.startsWith("before:")) { + const target = "/" + pos.slice(7); + const idx = items.findIndex((i) => i.path === target); + items.splice(idx >= 0 ? idx : items.length, 0, pluginItem); + } else { + items.push(pluginItem); + } + } + + return items; +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- export default function App() { const { t } = useI18n(); + const { plugins } = usePlugins(); + + const navItems = useMemo( + () => buildNavItems(BUILTIN_NAV, plugins), + [plugins], + ); return (
@@ -40,7 +116,7 @@ export default function App() {