From 1e11d0bb15ceef2092b2c0fb430e1650d3fd1308 Mon Sep 17 00:00:00 2001 From: William Wang Date: Tue, 25 Aug 2026 20:45:45 +0800 Subject: [PATCH] feat: interactive Ink REPL as bare zcode-acp default (ADR-0007) Unified CLI (ADR-0007): - bins pruned to zcode-acp + zcode-acp-server, both resolving to dist/cli.js; subcommands quota/hub/server, bare invocation opens the REPL - zcode-acp-hub and zcode-quota bins removed (files stay, spawned by path); README carries the 0.11 migration table - interactive Ink REPL: streaming output, tool rows, arrow-key permission picker, FIFO prompt queue, bulk-paste segmentation - entry points export main() behind cross-platform invokedDirectly guards; bare no-TTY invocation falls back to the stdio server (Windows shims lose the bin name from argv) - acpx dependency dropped before release; engines stay >=22.0.0 --- AGENTS.md | 12 +- CONTEXT.md | 14 +- README.md | 76 +++- docs/ARCHITECTURE.md | 2 +- docs/REMOTE-CLIENTS.md | 4 +- docs/TROUBLESHOOTING.md | 2 +- .../0007-unified-cli-entry-and-bin-pruning.md | 40 ++ package.json | 12 +- pnpm-lock.yaml | 353 ++++++++++++++++++ src/bin/hub.ts | 35 +- src/bin/quota.ts | 46 ++- src/cli.ts | 146 ++++++++ src/handlers/account.ts | 2 +- src/index.ts | 24 +- src/quota/color.ts | 2 +- src/quota/combined.ts | 8 +- src/quota/format.ts | 2 +- src/quota/opencode-go/index.ts | 2 +- src/quota/opencode-go/types.ts | 2 +- src/remote/config.ts | 2 +- src/remote/endpoint.ts | 2 +- src/remote/hub-server.ts | 2 +- src/repl/App.tsx | 248 ++++++++++++ src/repl/model.ts | 147 ++++++++ src/repl/run.ts | 260 +++++++++++++ tests/cli-quota.test.ts | 2 +- tests/cli.test.ts | 52 +++ tests/color.test.ts | 2 +- tests/repl-model.test.ts | 141 +++++++ tsconfig.json | 1 + 30 files changed, 1564 insertions(+), 79 deletions(-) create mode 100644 docs/adr/0007-unified-cli-entry-and-bin-pruning.md create mode 100755 src/cli.ts create mode 100644 src/repl/App.tsx create mode 100644 src/repl/model.ts create mode 100644 src/repl/run.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/repl-model.test.ts diff --git a/AGENTS.md b/AGENTS.md index 88342be..15b4302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,11 +58,17 @@ src/ │ ├── config.ts ENV parsing (gate, mandatory token, hub/bridge ports) │ ├── endpoint.ts Loopback ACP endpoint + hub registration heartbeat │ ├── file-endpoint.ts Read-only /fs/list + /fs/file, session-root scoped (ADR-0004) -│ └── hub-server.ts zcode-acp-hub: auth, discovery, byte-level proxy (ACP WS + /fs files), ?probe=1 liveness +│ └── hub-server.ts Hub daemon: auth, discovery, byte-level proxy (ACP WS + /fs files), ?probe=1 liveness ├── quota/ GLM Coding Plan usage API client (/quota command) +├── cli.ts Unified CLI entry (`zcode-acp`): subcommand dispatch +│ (bare invocation → REPL) (ADR-0007) +├── repl/ Interactive REPL (bare `zcode-acp`): Ink UI + ACP client +│ ├── model.ts Pure turn state machine (SessionUpdate → entries) +│ ├── App.tsx Ink components (stream, tools, permission picker) +│ └── run.ts Orchestration: spawn bridge, pump updates └── bin/ - ├── hub.ts Standalone zcode-acp-hub daemon entry - └── quota.ts Standalone zcode-quota CLI + ├── hub.ts Hub daemon entry (`zcode-acp hub`; spawned by absolute path) + └── quota.ts Quota cards entry (`zcode-acp quota`) ``` **Key boundary**: `backend/` talks to the ZCode subprocess. `handlers/` talks to diff --git a/CONTEXT.md b/CONTEXT.md index 8399729..d717532 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,8 +12,9 @@ session registry, and the turn loops. One editor connection = one bridge. _Avoid_: server (ambiguous with the ACP agent role), hub **Primary Client**: -The editor connection over stdio that spawned the bridge and owns its -lifetime (Zed, JetBrains). When it disconnects, the bridge exits. +The connection over stdio that spawned the bridge and owns its lifetime — an +editor (Zed, JetBrains) or the Unified CLI's REPL in a terminal. +When it disconnects, the bridge exits. _Avoid_: host, master client **Remote Client**: @@ -39,6 +40,15 @@ remote access. It does token auth, instance discovery, and byte-level WebSocket proxying — it holds no session state and understands no ACP. _Avoid_: gateway, broker +**Unified CLI**: +The `zcode-acp` command — the single human-facing command-line entry point. +Bare invocation opens the **REPL** (interactive agent chat in a terminal); +every other surface is a subcommand: `quota` (plan usage cards), `hub` +(running the Hub daemon), and `server` (the editor-facing bridge entry, +identical to the legacy `zcode-acp-server` bin kept for existing editor +configs). +_Avoid_: launcher, wrapper + **Instance**: One registered bridge as seen through the Hub. A remote connection binds to exactly one instance; instance switching means reconnecting. diff --git a/README.md b/README.md index b5781cd..7923c2e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ automatically. Point `ZCODE_BIN` at the bundled `zcode.cjs`: | `ZCODE_ACP_DEBUG` | _(unset)_ | Set to `1` to enable verbose diagnostic logs (event flow, probe loops, status updates). Default is quiet — only warnings (backend pipe errors, command/permission failures, lock timeouts) are emitted. Enable this when diagnosing bridge issues; the logs appear in `Zed.log` prefixed with `[zcode-acp]`. | | `ZCODE_ACP_REMOTE` | _(unset)_ | Set to `1` to enable [remote access](#remote-access) — serve the same sessions to additional ACP clients over WebSocket. | | `ZCODE_ACP_REMOTE_TOKEN` | _(unset)_ | Auth token for remote access. **Mandatory** when `ZCODE_ACP_REMOTE=1`; remote stays disabled without it. | -| `ZCODE_ACP_HUB_PORT` | `8377` | Port of the machine-level `zcode-acp-hub`. Map exactly this one port in your tunnel. | +| `ZCODE_ACP_HUB_PORT` | `8377` | Port of the machine-level hub daemon. Map exactly this one port in your tunnel. | | `ZCODE_ACP_HUB_HOST` | `127.0.0.1` | Hub bind address. `0.0.0.0` exposes a token-only, unencrypted surface — only for a containerized tunnel agent on a private interface (see [Remote Access](#remote-access)). | | `ZCODE_ACP_REMOTE_PORT` | `8378` | First loopback port for the bridge's ACP endpoint. Each bridge (each editor window) auto-increments to the next free port. | @@ -130,7 +130,7 @@ environment): } ``` -**Hub.** The first bridge with remote enabled spawns `zcode-acp-hub` as a +**Hub.** The first bridge with remote enabled spawns the hub daemon as a detached, machine-level singleton on `ZCODE_ACP_HUB_PORT` (it can also be run manually). It does three things only: token auth, instance discovery, and byte-level proxying (ACP WebSocket plus read-only session files) — no session @@ -185,10 +185,34 @@ the tunnel agent runs in its own container) is exactly as safe as the network it lands on. Keep the bind loopback unless that interface is private to the tunnel agent, and put TLS in front before mapping it anywhere untrusted. -## Standalone Quota CLI +## Unified CLI (`zcode-acp`) -Besides the ACP server, the package ships a `zcode-quota` bin that queries -your usage **from the terminal** — no editor or running server needed. By +Every surface of this package is available under one command — `zcode-acp` — +installed alongside the `zcode-acp-server` bin your editor configures. + +### Interactive REPL + +Bare `zcode-acp` opens an interactive terminal chat against this same bridge +(built with [Ink](https://github.com/vadimdemedes/ink), the same renderer +Claude Code and Gemini CLI use): + +```bash +zcode-acp # chat in this directory +``` + +Streaming output with code-fence coloring, dim thinking lines, live tool rows, +and arrow-key permission prompts. `Ctrl-C` cancels a running turn; while idle, +press it twice to quit. `/exit` leaves; the session itself persists in the +ZCode backend and is available to your editor. + +Without a TTY (pipes, Windows editor shims — where the bin name is lost from +`argv`), bare `zcode-acp` falls back to the stdio server, so editor configs +pointing at either bin name keep working. Ask for the REPL explicitly with +`zcode-acp repl`; without a TTY that errors instead of falling back. + +### Quota cards + +Check plan usage from the terminal — no editor or running server needed. By default it shows both **GLM Coding Plan** and **Opencode Go** in one card; pass a provider to focus on one. @@ -198,21 +222,21 @@ cookie — see [Opencode Go setup](#opencode-go-setup) below). ```bash # Both providers (default): GLM + Opencode Go in one card -zcode-quota +zcode-acp quota # Focus on one provider -zcode-quota glm # GLM Coding Plan only -zcode-quota go # Opencode Go only (rolling + weekly + monthly) +zcode-acp quota glm # GLM Coding Plan only +zcode-acp quota go # Opencode Go only (rolling + weekly + monthly) # Live monitor: clear the screen and refresh every 30s (default) -zcode-quota -w -zcode-quota go -w # watch Opencode Go only +zcode-acp quota -w +zcode-acp quota go -w # watch Opencode Go only # Refresh at a custom interval (seconds; minimum 10) -zcode-quota --watch --interval 60 +zcode-acp quota --watch --interval 60 # Plain monochrome bars (color is the default on a terminal) -zcode-quota --plain +zcode-acp quota --plain ``` By default the CLI renders heat-colored (green→yellow→red) progress bars with @@ -229,7 +253,7 @@ value. When the package isn't globally installed, run the built file directly: ```bash -node dist/bin/quota.js -w +node dist/cli.js quota -w ``` ### Opencode Go setup @@ -262,7 +286,27 @@ How to get the values: `Fe26.2**`). Without credentials, the default dual-provider mode silently shows GLM only -(no error). Running `zcode-quota go` without credentials prints a setup hint. +(no error). Running `zcode-acp quota go` without credentials prints a setup hint. + +### Hub and server subcommands + +`zcode-acp hub` runs the remote-access hub daemon manually (normally +auto-spawned by bridges — see [Remote Access](#remote-access)). `zcode-acp +server` speaks ACP on stdio — that is what editors invoke through the +`zcode-acp-server` bin; you rarely need it by hand. + +### Upgrading from 0.11 + +0.12.0 folds the old standalone bins into the unified CLI (see +[ADR-0007](docs/adr/0007-unified-cli-entry-and-bin-pruning.md)): + +| Old (≤0.11) | New (0.12) | +| --------------------- | ----------------------- | +| `zcode-acp-server` | unchanged (kept for editor configs) | +| `zcode-quota [args]` | `zcode-acp quota [args]` (same flags) | +| `zcode-acp-hub` | `zcode-acp hub` | + +Editor configs referencing `zcode-acp-server` keep working unchanged. ## ACP Registry @@ -298,8 +342,8 @@ The server is organised in layers that mirror the ACP protocol: - `interaction/` — bridge ZCode `interaction/*` server requests to ACP, preferring `elicitation/create` and falling back to `session/request_permission` (tool auth, ExitPlanMode, AskUserQuestion) - `handlers/` — ACP method handlers (`session/new`, `session/prompt`, ...) and the turn engine - `config/` — model / mode / thought-level configOptions and runtime model switching -- `remote/` — opt-in remote access: loopback ACP endpoint, multi-client broadcast, `zcode-acp-hub` registration -- `quota/` — GLM Coding Plan / Opencode Go usage API client (`/quota` command, `zcode-quota` bin) +- `remote/` — opt-in remote access: loopback ACP endpoint, multi-client broadcast, hub registration +- `quota/` — GLM Coding Plan / Opencode Go usage API client (`/quota` command, `zcode-acp quota` subcommand) - `server.ts` — shared state and handler registration - `index.ts` — stdio wiring via the ACP SDK diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b85f29..416d215 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -211,7 +211,7 @@ When enabled, the same `AgentApp` serves the stdio editor and a loopback WebSocket endpoint. Every connection (editor or remote) joins the broadcast registry via `trackConnections`, so one turn's notifications reach all clients regardless of who prompted. The bridge registers itself with the machine-level -`zcode-acp-hub` (`bin/hub.ts`), which is the only public entry point and holds +the hub daemon (`zcode-acp hub`, `bin/hub.ts`), which is the only public entry point and holds no session state (see `docs/adr/0002`). The bridge's lifetime still follows the stdio client (ADR-0001); the listener is `unref()`'d so remote clients alone never keep the process alive. diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md index 10fd16d..f89e511 100644 --- a/docs/REMOTE-CLIENTS.md +++ b/docs/REMOTE-CLIENTS.md @@ -2,7 +2,7 @@ How to attach any out-of-editor client — browser SPA, mobile app, CLI, desktop tool — to bridge sessions over the network. This document IS the contract: -everything here is implemented by `zcode-acp-hub` and the bridge's remote +everything here is implemented by the hub daemon (`zcode-acp hub`) and the bridge's remote endpoint; anything not written here is not part of the contract. ACP method semantics are defined by the [ACP spec](https://agentclientprotocol.com); @@ -182,7 +182,7 @@ pull-only request — callable any time after `initialize`, no session required. Fetch once after attach and on demand; quota changes are slow, there is no push. -Both channels return the same payload, mirroring the `zcode-quota` CLI card's +Both channels return the same payload, mirroring the `zcode-acp quota` CLI card's data model — one GLM section plus one Opencode Go section — so clients can reproduce the CLI layout exactly: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2731d07..8bbb51f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -341,7 +341,7 @@ http://127.0.0.1:/api/health` fails, or `/api/*` returns 401. 1. 401 means a token mismatch — `ZCODE_ACP_REMOTE_TOKEN` must be identical in the bridge env, the hub env (if run manually), and the client request. 2. A dead hub self-heals: the next bridge heartbeat (≤10s; worst ~1min under - the spawn throttle) re-spawns `zcode-acp-hub`. Retry with backoff rather + the spawn throttle) re-spawns the hub daemon. Retry with backoff rather than restarting anything by hand. 3. Confirm the ports match: the client must reach `ZCODE_ACP_HUB_PORT` (default 8377) through the tunnel, and the tunnel maps exactly that one diff --git a/docs/adr/0007-unified-cli-entry-and-bin-pruning.md b/docs/adr/0007-unified-cli-entry-and-bin-pruning.md new file mode 100644 index 0000000..6f0453b --- /dev/null +++ b/docs/adr/0007-unified-cli-entry-and-bin-pruning.md @@ -0,0 +1,40 @@ +# Unified CLI entry and bin pruning + +The package grew three separate bins (`zcode-acp-server`, `zcode-acp-hub`, +`zcode-quota`) plus a planned terminal chat surface. Installing four sibling +commands for one tool is noisy, and each new surface would add another. We +decided to ship one human-facing entry point — the Unified CLI, `zcode-acp` — +with every surface as a subcommand: `quota`, `hub`, and `server`. Bare +`zcode-acp` opens the interactive REPL: an Ink-rendered chat UI (the renderer +Claude Code and Gemini CLI use) speaking ACP to a spawned bridge over stdio, +with the update pump, permission picker, and turn state machine owned by this +package. The REPL is written in-house rather than borrowed — no embeddable +interactive ACP client library exists (acpx is headless-only; the interactive +clients Toad/Hydra/Nori are external tools or heavyweight daemons), and we +control both protocol ends already. + +Bin declarations are pruned to `zcode-acp` and `zcode-acp-server`. The server +bin stays because it is the command name hardcoded in existing editor configs +(Zed, JetBrains) across ~2.5k monthly downloads — removing it would break every +current user at once; it is an editor contract, not a human alias. The +`zcode-acp-hub` and `zcode-quota` bins are removed in 0.12.0 with a README +migration note (`zcode-acp hub` / `zcode-acp quota`); the hub's own binary file +stays in `dist/bin/hub.js` because bridges spawn it by absolute path, not by +command name, so the remote feature is unaffected. + +Bin-name detection is `basename(argv[1])`, which works through Unix symlinks +but not through Windows `.cmd` shims (they spawn `node ...\dist\cli.js`, so +the bin name never reaches argv). Bare invocation without a TTY therefore +falls back to the stdio server — the only sensible reading of a piped, no-TTY +launch, and exactly what a Windows editor spawn needs. The explicit +`zcode-acp repl` subcommand keeps a hard TTY check and errors instead. + +We rejected keeping all old bins as silent aliases (five installed commands; +the redundancy was the original complaint), removing every old bin including +`zcode-acp-server` (breaks all existing editor configs at once; revisit at +1.0), and bundling [acpx](https://github.com/openclaw/acpx) as a `chat` +passthrough subcommand (evaluated first: its engine is headless-only, so it +cannot host the interactive REPL that is the actual requirement, and keeping +it beside the in-house REPL would mean two terminal surfaces, a pre-1.0 +dependency, and an extra Node engine bump for no remaining use — dropped +before release). diff --git a/package.json b/package.json index b1c84cd..f68a854 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.9", + "version": "0.12.0", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", @@ -23,9 +23,8 @@ "zcode" ], "bin": { - "zcode-acp-server": "dist/index.js", - "zcode-acp-hub": "dist/bin/hub.js", - "zcode-quota": "dist/bin/quota.js" + "zcode-acp": "dist/cli.js", + "zcode-acp-server": "dist/cli.js" }, "main": "dist/index.js", "types": "dist/index.d.ts", @@ -48,6 +47,10 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", + "chalk": "^6.0.0", + "ink": "^7.1.1", + "ink-spinner": "^5.0.0", + "react": "^19.2.8", "ws": "^8.21.3" }, "peerDependencies": { @@ -56,6 +59,7 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/react": "^19.2.18", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b38c07..cc6ce1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,18 @@ importers: '@agentclientprotocol/sdk': specifier: ^1.3.0 version: 1.3.0(zod@3.25.76) + chalk: + specifier: ^6.0.0 + version: 6.0.0 + ink: + specifier: ^7.1.1 + version: 7.1.1(@types/react@19.2.18)(react@19.2.8) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@7.1.1(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 ws: specifier: ^8.21.3 version: 8.21.3 @@ -21,6 +33,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.20.0 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -53,6 +68,10 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -399,6 +418,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -503,10 +525,22 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -514,6 +548,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -544,10 +582,38 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -558,10 +624,17 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -578,14 +651,25 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -681,6 +765,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -709,14 +797,47 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + 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 + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -753,6 +874,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -771,6 +896,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -787,6 +916,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -826,15 +959,32 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -851,16 +1001,35 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -869,6 +1038,14 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -901,6 +1078,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -983,10 +1164,18 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@10.0.1: + resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} + engines: {node: '>=20'} + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -1003,6 +1192,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1012,6 +1204,11 @@ snapshots: dependencies: zod: 3.25.76 + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1228,6 +1425,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -1376,14 +1577,24 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + argparse@2.0.1: {} assertion-error@2.0.1: {} + auto-bind@5.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -1414,8 +1625,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + + chalk@6.0.0: {} + check-error@2.1.3: {} + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-spinners@2.9.2: {} + + cli-truncate@6.1.1: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.2 + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1424,12 +1656,16 @@ snapshots: concat-map@0.0.1: {} + convert-to-spaces@2.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1438,8 +1674,12 @@ snapshots: deep-is@0.1.4: {} + environment@1.1.0: {} + es-module-lexer@1.7.0: {} + es-toolkit@1.51.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1466,6 +1706,8 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -1571,6 +1813,8 @@ snapshots: fsevents@2.3.3: optional: true + get-east-asian-width@1.6.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -1590,12 +1834,60 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + + ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.18)(react@19.2.8))(react@19.2.8): + dependencies: + cli-spinners: 2.9.2 + ink: 7.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + + ink@7.1.1(@types/react@19.2.18)(react@19.2.8): + 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.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.51.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.8 + react-reconciler: 0.33.0(react@19.2.8) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.2 + terminal-size: 4.0.1 + type-fest: 5.8.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.1 + ws: 8.21.3 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.18 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + is-extglob@2.1.1: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ci@2.0.0: {} + isexe@2.0.0: {} js-yaml@4.3.0: @@ -1629,6 +1921,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mimic-fn@2.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -1643,6 +1937,10 @@ snapshots: natural-compare@1.4.0: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1664,6 +1962,8 @@ snapshots: dependencies: callsites: 3.1.0 + patch-console@2.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -1688,8 +1988,20 @@ snapshots: punycode@2.3.1: {} + react-reconciler@0.33.0(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + resolve-from@4.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -1721,6 +2033,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + scheduler@0.27.0: {} + semver@7.8.5: {} shebang-command@2.0.0: @@ -1731,18 +2045,42 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@3.10.0: {} + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1766,6 +2104,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} undici-types@6.21.0: {} @@ -1845,10 +2187,21 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.2 + word-wrap@1.2.5: {} + wrap-ansi@10.0.1: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + ws@8.21.3: {} yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zod@3.25.76: {} diff --git a/src/bin/hub.ts b/src/bin/hub.ts index 3ca183e..d8ff5b3 100644 --- a/src/bin/hub.ts +++ b/src/bin/hub.ts @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * Standalone zcode-acp-hub daemon entry. + * Standalone zcode-acp hub daemon entry. * * Usually spawned detached by the first bridge that enables remote access * (see src/remote/endpoint.ts); running it manually is also fine, e.g. under * launchd/systemd or directly for debugging: * - * ZCODE_ACP_REMOTE_TOKEN= zcode-acp-hub + * ZCODE_ACP_REMOTE_TOKEN= zcode-acp hub * * Refuses to start without ZCODE_ACP_REMOTE_TOKEN — the hub is the only public * entry point and never runs unauthenticated. Exits 0 on EADDRINUSE: another @@ -48,7 +48,7 @@ function respawnSelf(): void { process.exit(0); } -async function main(): Promise { +export async function main(): Promise { const config = parseHubConfig(); if (!config) process.exit(1); const hub = await startHub({ @@ -62,12 +62,23 @@ async function main(): Promise { process.on("SIGINT", () => void hub.close().then(() => process.exit(0))); } -main().catch((err) => { - const message = err instanceof Error ? err.message : String(err); - if ((err as NodeJS.ErrnoException)?.code === "EADDRINUSE") { - // Another hub already listens on this port — nothing to do. - process.exit(0); - } - warn(`hub: fatal: ${message}`); - process.exit(1); -}); +// Only auto-run when invoked directly (not when imported by the Unified CLI +// dispatcher). The hub file itself is also spawned by absolute path from +// src/remote/endpoint.ts, so dist/bin/hub.js must keep working standalone — +// including on Windows, where argv[1] is a backslash path. +const invokedDirectly = (() => { + const entry = (process.argv[1] ?? "").replace(/\\/g, "/"); + return entry.endsWith("bin/hub.js") || entry.endsWith("bin/hub.ts"); +})(); + +if (invokedDirectly) { + main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + if ((err as NodeJS.ErrnoException)?.code === "EADDRINUSE") { + // Another hub already listens on this port — nothing to do. + process.exit(0); + } + warn(`hub: fatal: ${message}`); + process.exit(1); + }); +} diff --git a/src/bin/quota.ts b/src/bin/quota.ts index 38d1700..b99ec1e 100644 --- a/src/bin/quota.ts +++ b/src/bin/quota.ts @@ -8,12 +8,12 @@ * credentials and hits the quota HTTP API directly. * * Usage: - * zcode-quota one-shot: print the card and exit - * zcode-quota -w watch mode (default 30s refresh) - * zcode-quota -w -i 60 watch with a 60s interval - * zcode-quota -d show per-model MCP detail sub-lines - * zcode-quota --watch --interval 15 - * zcode-quota -h | --help show help + * zcode-acp quota one-shot: print the card and exit + * zcode-acp quota -w watch mode (default 30s refresh) + * zcode-acp quota -w -i 60 watch with a 60s interval + * zcode-acp quota -d show per-model MCP detail sub-lines + * zcode-acp quota --watch --interval 15 + * zcode-acp quota -h | --help show help * * The refresh interval has a 10s floor: the in-memory quota cache TTL is 10s, * and a shorter interval would just keep returning the cached value. @@ -65,7 +65,7 @@ export interface CliOptions { } /** Human-readable usage text. */ -const HELP_TEXT = `Usage: zcode-quota [provider] [options] +const HELP_TEXT = `Usage: zcode-acp quota [provider] [options] Query usage from the terminal. By default shows both GLM Coding Plan and Opencode Go in one card; pass a provider to focus on one. @@ -92,12 +92,12 @@ Options: -h, --help Show this help and exit. Examples: - zcode-quota # both providers, print once and exit (color bars) - zcode-quota go # Opencode Go only (3 windows) - zcode-quota glm -w # GLM only, live monitor every 30s - zcode-quota -w -i 60 # both, refresh every 60s - zcode-quota -d # both, include per-model MCP breakdown - zcode-quota --plain # both, classic monochrome bars`; + zcode-acp quota # both providers, print once and exit (color bars) + zcode-acp quota go # Opencode Go only (3 windows) + zcode-acp quota glm -w # GLM only, live monitor every 30s + zcode-acp quota -w -i 60 # both, refresh every 60s + zcode-acp quota -d # both, include per-model MCP breakdown + zcode-acp quota --plain # both, classic monochrome bars`; /** * Clamp a raw interval (seconds, optional) to a valid ms value. Returns the @@ -168,7 +168,7 @@ export function parseArgs(argv: readonly string[]): CliOptions { if (Number.isFinite(n)) interval = n; } else if (arg === "glm" || arg === "go") { // First positional provider token. Only honor the first; a second - // (e.g. `zcode-quota glm go`) is ignored to keep parsing simple. + // (e.g. `zcode-acp quota glm go`) is ignored to keep parsing simple. if (provider === "all") provider = arg; } // Other non-flag tokens are ignored (forward-compat / typos). @@ -351,8 +351,15 @@ async function runOnce(provider: Provider, detail: boolean, color: boolean): Pro process.stdout.write(out + "\n"); } -async function main(): Promise { - const opts = parseArgs(process.argv.slice(2)); +/** + * CLI entry. Takes the argument list (defaulting to the process argv) so the + * Unified CLI dispatcher can pass its own slice (`zcode-acp quota -w` → + * `["-w"]`) without the subcommand name leaking in as a provider token. + * + * Exported for the Unified CLI dispatcher; guarded auto-run below. + */ +export async function main(argv: readonly string[] = process.argv.slice(2)): Promise { + const opts = parseArgs(argv); if (opts.help) { process.stdout.write(HELP_TEXT + "\n"); @@ -360,7 +367,7 @@ async function main(): Promise { } if (opts.intervalClamped) { - process.stderr.write(`zcode-quota: interval below 10s raised to 10s (cache TTL is 10s)\n`); + process.stderr.write(`zcode-acp quota: interval below 10s raised to 10s (cache TTL is 10s)\n`); } // Color is on by default on a real terminal; turn it off when piped/ @@ -380,14 +387,15 @@ async function main(): Promise { // Only auto-run when invoked directly (not when imported by tests). In an ESM // build there is no `require.main`, so fall back to a heuristic: if argv[1] // (the executed script) ends with this file's path, we are the entry point. +// Backslashes are normalized because argv[1] on Windows is a backslash path. const invokedDirectly = (() => { - const entry = process.argv[1] ?? ""; + const entry = (process.argv[1] ?? "").replace(/\\/g, "/"); return entry.endsWith("bin/quota.js") || entry.endsWith("bin/quota.ts"); })(); if (invokedDirectly) { main().catch((err) => { - process.stderr.write(`zcode-quota: ${err instanceof Error ? err.message : String(err)}\n`); + process.stderr.write(`zcode-acp quota: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); }); } diff --git a/src/cli.ts b/src/cli.ts new file mode 100755 index 0000000..5873869 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +/** + * Unified CLI entry (`zcode-acp`). Every operational surface is a subcommand; + * bare invocation opens the interactive REPL. See docs/adr/0007 for why the + * old `zcode-acp-hub` / `zcode-quota` bins were folded in here and why + * `zcode-acp-server` remains as a bin alias pointing at this same file. + * + * The legacy alias is detected via argv[0]: npm/pnpm install bin names as + * symlinks, so both `zcode-acp` and `zcode-acp-server` resolve to dist/cli.js + * while Node keeps the invoked path in argv — basename tells us which name the + * user (or editor config) actually typed. + */ + +import { basename } from "node:path"; +import process from "node:process"; + +import { main as runHub } from "./bin/hub.js"; +import { main as runQuota } from "./bin/quota.js"; +import { main as runServer } from "./index.js"; +import { runRepl } from "./repl/run.js"; +import { AGENT_INFO } from "./utils.js"; + +/** What the dispatcher decided to run. `args` are the tokens after the subcommand. */ +export type Invocation = + | { kind: "help" } + | { kind: "repl"; explicit: boolean } + | { kind: "server" } + | { kind: "hub" } + | { kind: "quota"; args: string[] } + | { kind: "unknown"; sub: string }; + +/** + * Map (invoked name, argv) to a subcommand. Pure — exported for unit tests. + * + * `invokedAs` is basename(argv[1]); `zcode-acp-server` means we were spawned + * by an editor config that expects the bridge to speak ACP on stdio with no + * subcommand prefix. Bare `zcode-acp` opens the interactive REPL. + */ +export function resolveInvocation(invokedAs: string, argv: readonly string[]): Invocation { + if (invokedAs === "zcode-acp-server") return { kind: "server" }; + const sub = argv[0]; + if (sub === undefined) return { kind: "repl", explicit: false }; + if (sub === "repl" || sub === "tui") return { kind: "repl", explicit: true }; + if (sub === "-h" || sub === "--help" || sub === "help") { + return { kind: "help" }; + } + switch (sub) { + case "server": + return { kind: "server" }; + case "hub": + return { kind: "hub" }; + case "quota": + return { kind: "quota", args: argv.slice(1) }; + default: + return { kind: "unknown", sub }; + } +} + +const HELP_TEXT = `Usage: zcode-acp [command] [options] + +The single entry point for every zcode-acp-server surface. Bare invocation +opens the interactive REPL (agent chat in this terminal). + +Commands: + (none) | repl Interactive agent chat (Ink UI): stream output, tool + rows, arrow-key permission prompts. /exit quits. + quota [args...] Plan usage cards (was the zcode-quota bin): -w watch, + -i , -d detail, -p plain, provider glm|go. + hub Run the remote-access hub daemon (was zcode-acp-hub; + usually auto-spawned by bridges, rarely run by hand). + server The editor-facing ACP bridge over stdio (was + zcode-acp-server; editors normally launch it via the bin + alias without this subcommand). + -h, --help Show this help. + --version Show the package version. + +Examples: + zcode-acp # chat interactively in this repo + zcode-acp quota -w # live usage monitor + zcode-acp server # stdio bridge (for testing)`; + +async function main(): Promise { + if (process.argv[2] === "--version") { + process.stdout.write(`${AGENT_INFO.version}\n`); + return; + } + const invocation = resolveInvocation(basename(process.argv[1] ?? ""), process.argv.slice(2)); + switch (invocation.kind) { + case "help": + process.stdout.write(HELP_TEXT + "\n"); + return; + case "repl": + if (process.stdin.isTTY && process.stdout.isTTY) { + await runRepl(); + return; + } + if (invocation.explicit) { + process.stderr.write("zcode-acp: interactive REPL needs a TTY — run it from a terminal.\n"); + process.exit(2); + } + // Bare invocation without a TTY falls back to the stdio server: on + // Windows npm shims spawn `node ...\dist\cli.js`, so argv[0] loses the + // bin name and editors configured with either bin land here. Unix + // pipes get the same fallback (JSON-RPC on stdin, exit on EOF). + await runServer(); + return; + case "server": + await runServer(); + return; + case "hub": + await runHub(); + return; + case "quota": + await runQuota(invocation.args); + return; + case "unknown": + process.stderr.write(`zcode-acp: unknown command '${invocation.sub}'\n\n`); + process.stdout.write(HELP_TEXT + "\n"); + process.exit(1); + } +} + +// Only auto-run when this file is the executed entry — directly +// (`node dist/cli.js`) or through either bin symlink (`zcode-acp`, +// `zcode-acp-server`), where argv[1] keeps the symlink path. Imports (the +// test suite) fall through to a no-op. +const invokedDirectly = (() => { + const entry = process.argv[1] ?? ""; + const name = basename(entry); + return ( + entry.endsWith("cli.js") || + entry.endsWith("cli.ts") || + name === "zcode-acp" || + name === "zcode-acp-server" + ); +})(); + +if (invokedDirectly) { + main().catch((err) => { + process.stderr.write( + `zcode-acp: fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`, + ); + process.exit(1); + }); +} diff --git a/src/handlers/account.ts b/src/handlers/account.ts index fb9b0de..721327e 100644 --- a/src/handlers/account.ts +++ b/src/handlers/account.ts @@ -1,7 +1,7 @@ /** * Account-level usage stats — Proposal 0002 (`account/usage_stats`). * - * Exposes the combined dual-provider quota behind the `zcode-quota` CLI + * Exposes the combined dual-provider quota behind the `zcode-acp quota` CLI * (GLM Coding Plan + Opencode Go) to remote clients as a pull-only ACP * method, callable any time after `initialize` (no session required — quota * is account-level, so it fits no `session/update` kind). diff --git a/src/index.ts b/src/index.ts index a0a5fa9..29c0eba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,9 @@ * delimited JSON-RPC over a pair of web streams. We convert Node's stdin/ * stdout to web streams and hand them off. Everything else (request/response * correlation, param validation, AbortSignal plumbing) is handled by the SDK. + * + * Invoked directly (`node dist/index.js`) or via the Unified CLI's `server` + * subcommand / the `zcode-acp-server` bin alias (both resolve to `dist/cli.js`). */ import { Readable, Writable } from "node:stream"; @@ -67,7 +70,7 @@ function buildAllCommands() { return merged; } -async function main(): Promise { +export async function main(): Promise { // stdout is the outbound channel to the client; stdin is inbound. const outbound = Writable.toWeb(process.stdout); const inbound = Readable.toWeb(process.stdin) as ReadableStream; @@ -214,7 +217,18 @@ async function main(): Promise { } } -main().catch((err) => { - warn(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); - process.exit(1); -}); +// Only auto-run when invoked directly (not when imported by the Unified CLI +// dispatcher). In an ESM build there is no `require.main`, so fall back to an +// entry-path heuristic like src/bin/quota.ts uses. Backslashes are normalized +// because argv[1] on Windows is a backslash path. +const invokedDirectly = (() => { + const entry = (process.argv[1] ?? "").replace(/\\/g, "/"); + return entry.endsWith("dist/index.js") || entry.endsWith("src/index.ts"); +})(); + +if (invokedDirectly) { + main().catch((err) => { + warn(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); + process.exit(1); + }); +} diff --git a/src/quota/color.ts b/src/quota/color.ts index 84dcdcb..5cdf5fc 100644 --- a/src/quota/color.ts +++ b/src/quota/color.ts @@ -1,5 +1,5 @@ /** - * Color progress-bar rendering for the `zcode-quota` CLI. + * Color progress-bar rendering for the `zcode-acp quota` CLI. * * The default CLI view overlays the usage numbers directly onto a heat-colored * (green→yellow→red) bar using 24-bit ANSI background colors, so the percentage diff --git a/src/quota/combined.ts b/src/quota/combined.ts index 40d0ff3..bff1c91 100644 --- a/src/quota/combined.ts +++ b/src/quota/combined.ts @@ -1,6 +1,6 @@ /** * Combined multi-provider quota — the orchestration layer used by the - * `zcode-quota` CLI when no provider subcommand is given (default mode). + * `zcode-acp quota` CLI when no provider subcommand is given (default mode). * * Queries GLM Coding Plan and Opencode Go in parallel and renders a single * merged card with one section per provider. The `/quota` slash command does @@ -177,8 +177,8 @@ function renderCombinedLines( const glmOptsColor: FormatOptions = { ...glmOpts, color }; // Single-provider GLM: behave like the original card (header + divider + - // body) minus the fence, so `zcode-quota glm` looks identical to today's - // `zcode-quota`. + // body) minus the fence, so `zcode-acp quota glm` looks identical to today's + // `zcode-acp quota`. if (provider === "glm") { const lines = renderSingleGlm(glm, glmOptsColor); // The separator rides after the (only) section, then nothing follows. @@ -229,7 +229,7 @@ function renderCombinedLines( /** * Render a single GLM provider as header + divider + body (the classic card, - * minus the fence). Used for `zcode-quota glm`. + * minus the fence). Used for `zcode-acp quota glm`. */ function renderSingleGlm(result: QuotaResult, opts?: FormatOptions): string[] { const section = renderGlmSection(result, opts); diff --git a/src/quota/format.ts b/src/quota/format.ts index f4b7b7c..681685d 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -117,7 +117,7 @@ const DETAIL_LABEL_WIDTH = 14; * - `color` (default `false`): render the bar as a heat-colored (green→red) * 24-bit ANSI bar with the usage numbers overlaid inside (see * {@link pickOverlay}), leaving only the reset time on the right margin. - * Only the `zcode-quota` CLI sets this (gated on `stdout.isTTY`); the + * Only the `zcode-acp quota` CLI sets this (gated on `stdout.isTTY`); the * `/quota` slash command never does, so its fenced ```text card stays plain * and copy-paste-safe. */ diff --git a/src/quota/opencode-go/index.ts b/src/quota/opencode-go/index.ts index ff5e7d5..6467478 100644 --- a/src/quota/opencode-go/index.ts +++ b/src/quota/opencode-go/index.ts @@ -1,6 +1,6 @@ /** * Opencode Go usage orchestration — the entry point used by the - * `zcode-quota` CLI. + * `zcode-acp quota` CLI. * * Flow: credentials (env + config file) → cache check → fetch → redirect/auth * check → parse → cache write. Any thrown error degrades to `unavailable` diff --git a/src/quota/opencode-go/types.ts b/src/quota/opencode-go/types.ts index 4cfdbb2..07955f0 100644 --- a/src/quota/opencode-go/types.ts +++ b/src/quota/opencode-go/types.ts @@ -1,7 +1,7 @@ /** * Type definitions for the Opencode Go subscription usage feature. * - * The `zcode-quota` CLI queries the opencode.ai web dashboard (there is no + * The `zcode-acp quota` CLI queries the opencode.ai web dashboard (there is no * JSON API yet) by scraping the SolidJS SSR hydration payload embedded in * `https://opencode.ai/workspace//go`. Usage is split into three windows: * rolling (5h), weekly (7d), monthly (30d). Each window carries only a diff --git a/src/remote/config.ts b/src/remote/config.ts index 2d332dd..9e59adc 100644 --- a/src/remote/config.ts +++ b/src/remote/config.ts @@ -60,7 +60,7 @@ export function parseRemoteConfig(env: NodeJS.ProcessEnv = process.env): RemoteC }; } -/** Parse hub-side config for the standalone `zcode-acp-hub` bin. */ +/** Parse hub-side config for the standalone hub entry (`zcode-acp hub`). */ export function parseHubConfig(env: NodeJS.ProcessEnv = process.env): RemoteConfig | null { const token = (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim(); if (!token) { diff --git a/src/remote/endpoint.ts b/src/remote/endpoint.ts index fd4e464..72197fd 100644 --- a/src/remote/endpoint.ts +++ b/src/remote/endpoint.ts @@ -6,7 +6,7 @@ * (SDK AcpServer transport). Each remote connection gets its own JSON-RPC id * space; fan-out to all clients is handled by the broadcast registry, not * here. This endpoint is intentionally NOT exposed to the network — the hub - * (`zcode-acp-hub`) is the single public entry and proxies into it. + * (`zcode-acp hub`) is the single public entry and proxies into it. * * The bridge also registers itself with the hub (spawning one if none is * listening) and re-registers every 10s as a heartbeat carrying fresh session diff --git a/src/remote/hub-server.ts b/src/remote/hub-server.ts index 3a33a99..4b4d919 100644 --- a/src/remote/hub-server.ts +++ b/src/remote/hub-server.ts @@ -1,5 +1,5 @@ /** - * zcode-acp-hub — machine-level singleton for remote access. + * Hub daemon — machine-level singleton for remote access. * * The hub is the ONLY public entry point (the port a tunnel maps). It does * token auth, instance discovery, and byte-level WebSocket proxying from a diff --git a/src/repl/App.tsx b/src/repl/App.tsx new file mode 100644 index 0000000..465ce03 --- /dev/null +++ b/src/repl/App.tsx @@ -0,0 +1,248 @@ +/** + * Ink UI for the interactive REPL (bare `zcode-acp`). + * + * Rendering model: completed transcript entries go to (appended once, + * becomes native terminal scrollback); the live turn (streaming text, thinking + * buffer, tool rows) plus the input line re-render below it. A pending + * permission request takes over the input area with an arrow-key picker. + */ + +import { Box, Static, Text, useInput } from "ink"; +import Spinner from "ink-spinner"; +import { Chalk } from "chalk"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +import { splitBulkInput, type ReplEntry, type TurnState } from "./model.js"; + +/** A permission request awaiting the user's choice. */ +export interface PermissionPrompt { + title: string; + options: Array<{ id: string; name: string }>; +} + +export interface AppProps { + /** Completed transcript entries (history). */ + entries: ReplEntry[]; + /** Live turn in progress, null when idle. */ + turn: TurnState | null; + /** Pending permission request, null when none. */ + permission: PermissionPrompt | null; + /** True while the bridge/session is starting up. */ + busy: boolean; + onSubmit: (text: string) => void; + onCancelTurn: () => void; + onAnswerPermission: (optionId: string | null) => void; + onExit: () => void; +} + +// Fixed-level chalk so colorization is deterministic regardless of TTY +// detection (tests run with level 0; the REPL terminal always takes color). +const color = new Chalk({ level: 2 }); + +/** + * Colorize markdown code fences: lines between ``` fences render cyan. Line + * level state machine — exported for unit tests. + */ +export function colorizeCodeFences(text: string): string { + let inFence = false; + return text + .split("\n") + .map((line) => { + if (line.trimStart().startsWith("```")) { + inFence = !inFence; + return color.dim(line); + } + return inFence ? color.cyan(line) : line; + }) + .join("\n"); +} + +const TOOL_STATUS_ICON: Record = { + pending: "○", + in_progress: "●", + completed: "✔", + failed: "✘", +}; + +function EntryView({ entry }: { entry: ReplEntry }): ReactElement { + switch (entry.kind) { + case "user": + return ( + + + >{" "} + + {entry.text} + + ); + case "thinking": { + const oneLine = entry.text.replace(/\s+/g, " ").slice(0, 120); + return ⎿ thinking · {oneLine}; + } + case "assistant": + return {colorizeCodeFences(entry.text)}; + case "tool": + return ( + + + {TOOL_STATUS_ICON[entry.status] ?? "•"}{" "} + + {entry.title} + ({entry.status}) + + ); + case "note": + return -- {entry.text}; + } +} + +/** + * Self-managed input line. ink-text-input's submit/clear timing proved + * unreliable under full external rerenders, so the line owns its value and + * handles plain typing, backspace, and submit directly — first version has + * no cursor movement or history, which is fine for a REPL. + */ +function InputLine({ + busy, + onSubmitText, +}: { + busy: boolean; + onSubmitText: (text: string) => void; +}): ReactElement { + const [value, setValue] = useState(""); + const submitWith = (text: string): void => { + setValue(""); + if (text.trim()) onSubmitText(text.trim()); + }; + useInput((inputChar, key) => { + if (key.return) { + submitWith(value); + return; + } + if (key.backspace || key.delete) { + setValue((v) => v.slice(0, -1)); + return; + } + if (inputChar && !key.ctrl && !key.meta) { + // Bulk stdin writes (pasted text, scripted ptys) arrive as ONE event + // that may embed \r/\n — ink only maps a lone \r to key.return. Treat + // every embedded CR/LF as a submit boundary; the last segment stays + // buffered as the new in-progress line. + if (/[\r\n]/.test(inputChar)) { + const { submits, buffer } = splitBulkInput(value, inputChar); + for (const line of submits) { + submitWith(line); + } + setValue(buffer); + return; + } + setValue((v) => v + inputChar); + } + }); + return ( + + {busy ? "starting… " : "❯ "} + {value} + + + ); +} + +export function App(props: AppProps): ReactElement { + const { entries, turn, permission, busy } = props; + // Second consecutive idle Ctrl-C exits; a turn-running Ctrl-C only cancels. + const idleIntCount = useRef(0); + + const submit = useCallback( + (text: string) => { + props.onSubmit(text); + }, + [props], + ); + + useInput((inputChar, key) => { + if (permission) { + // The picker owns the keyboard; selection state lives there. + return; + } + if (key.ctrl && inputChar === "c") { + if (turn) { + props.onCancelTurn(); + idleIntCount.current = 0; + } else { + idleIntCount.current += 1; + if (idleIntCount.current >= 2) props.onExit(); + } + return; + } + idleIntCount.current = 0; + }); + + return ( + + {(entry, i) => } + + {turn ? ( + + {turn.entries.map((e, i) => ( + + ))} + {turn.thinkBuf.trim() ? ( + ⎿ thinking · {turn.thinkBuf.replace(/\s+/g, " ").slice(-120)} + ) : null} + {turn.textBuf ? {colorizeCodeFences(turn.textBuf)} : null} + + ctrl-c to cancel + + + ) : null} + + {permission ? ( + props.onAnswerPermission(null)} + /> + ) : ( + + )} + + ); +} + +function PermissionPicker({ + prompt, + onAnswer, + onCancel, +}: { + prompt: PermissionPrompt; + onAnswer: (optionId: string) => void; + onCancel: () => void; +}): ReactElement { + const [index, setIndex] = useState(0); + useInput((inputChar, key) => { + if (key.upArrow) setIndex((i) => Math.max(0, i - 1)); + else if (key.downArrow) setIndex((i) => Math.min(prompt.options.length - 1, i + 1)); + else if (key.return) onAnswer(prompt.options[index]!.id); + else if (key.escape || (key.ctrl && inputChar === "c")) onCancel(); + }); + return ( + + permission requested + {prompt.title} + {prompt.options.map((opt, i) => ( + + + {i === index ? "❯ " : " "} + {opt.name} + + + ))} + ↑/↓ select · enter confirm · esc cancel + + ); +} diff --git a/src/repl/model.ts b/src/repl/model.ts new file mode 100644 index 0000000..d55c60e --- /dev/null +++ b/src/repl/model.ts @@ -0,0 +1,147 @@ +/** + * Pure REPL turn state machine: folds ACP SessionUpdate notifications for one + * prompt turn into renderable entries. No React, no I/O — exported for unit + * tests. + * + * Chunk ordering follows what the bridge emits per turn: thought chunks and + * message chunks arrive interleaved as streams (buffered until the stream + * switches), tool calls arrive as discrete events keyed by toolCallId, and a + * tool_call_update may later change only the status of an existing row. + */ + +import type { SessionUpdate } from "@agentclientprotocol/sdk"; + +/** Alias so tests can build fixtures without importing SDK types directly. */ +export type SessionUpdateLike = SessionUpdate; + +/** One rendered line (or block) in the transcript. */ +export type ReplEntry = + | { kind: "user"; text: string } + | { kind: "thinking"; text: string } + | { kind: "assistant"; text: string } + | { kind: "tool"; id?: string; title: string; status: string } + | { kind: "note"; text: string }; + +/** Live snapshot of a turn in progress (null textBuf/thinkBuf = not streaming). */ +export interface TurnState { + entries: ReplEntry[]; + textBuf: string; + thinkBuf: string; +} + +export function createTurnState(): TurnState { + return { entries: [], textBuf: "", thinkBuf: "" }; +} + +/** Extract displayable text from any content block shape (text/thought). */ +function blockText(content: unknown): string { + if (content && typeof content === "object" && "text" in content) { + const t = (content as { text?: unknown }).text; + return typeof t === "string" ? t : ""; + } + return ""; +} + +/** + * Apply one update to the live turn state. Returns a NEW state object (safe + * for React-style always-replace updates). Unknown update kinds are ignored. + */ +export function applyUpdate(state: TurnState, update: SessionUpdate): TurnState { + const next: TurnState = { + entries: state.entries, + textBuf: state.textBuf, + thinkBuf: state.thinkBuf, + }; + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const chunk = blockText(update.content); + // A message chunk after thinking means the thought stream ended — flush + // it as its own dim entry before the prose starts. + if (chunk && next.thinkBuf) { + next.entries = [...next.entries, { kind: "thinking", text: next.thinkBuf.trim() }]; + next.thinkBuf = ""; + } + next.textBuf = next.textBuf + chunk; + return next; + } + case "agent_thought_chunk": { + next.thinkBuf = next.thinkBuf + blockText(update.content); + return next; + } + case "tool_call": + case "tool_call_update": { + const id = update.toolCallId ?? ""; + const title = update.title ?? id; + const status = update.status ?? "pending"; + const idx = next.entries.findIndex((e) => e.kind === "tool" && e.id === id && id !== ""); + // tool_call_update may arrive for a call we never saw (pre-turn replay + // leftovers); render it as a fresh row instead of dropping it. + if (idx >= 0) { + const old = next.entries[idx]!; + if (old.kind === "tool") { + const entries = [...next.entries]; + entries[idx] = { kind: "tool", id, title: update.title ?? old.title, status }; + next.entries = entries; + } + } else { + next.entries = [...next.entries, { kind: "tool", id, title, status }]; + } + return next; + } + case "plan": { + // First version renders plans as a one-line note; full plan UI is a + // follow-up. Counts entries when the shape provides them. + const items = Array.isArray(update.entries) ? update.entries.length : 0; + next.entries = [ + ...next.entries, + { kind: "note", text: items > 0 ? `plan · ${items} steps` : "plan updated" }, + ]; + return next; + } + default: + return next; + } +} + +/** + * Close out a turn: flush any pending thought/text buffers as final entries + * and append the stop-reason note. Returns only the finished entries — the + * turn state is discarded afterwards. + */ +export function finishTurn(state: TurnState, stopReason?: string): ReplEntry[] { + const entries = [...state.entries]; + if (state.thinkBuf.trim()) entries.push({ kind: "thinking", text: state.thinkBuf.trim() }); + if (state.textBuf.trim()) entries.push({ kind: "assistant", text: state.textBuf.trim() }); + if (stopReason && stopReason !== "end_turn") { + entries.push({ kind: "note", text: `stopped: ${stopReason}` }); + } + return entries; +} + +/** REPL meta-commands. Everything else is a prompt. */ +export type ReplCommand = "exit" | null; + +export function parseCommand(text: string): ReplCommand { + const t = text.trim(); + if (t === "/exit" || t === "/quit" || t === "/q") return "exit"; + return null; +} + +/** + * Segment a bulk stdin event (paste, scripted pty) that embeds CR/LF. Ink maps + * only a LONE \r to key.return, so a multi-line write arrives as one printable + * event. The first segment completes the in-progress line (`value` prefix); + * every middle segment submits standalone; the final segment stays buffered as + * the new in-progress line. Empty submits are dropped by the caller's trim. + */ +export function splitBulkInput( + value: string, + input: string, +): { submits: string[]; buffer: string } { + const parts = input.split(/[\r\n]+/); + const submits = [value + (parts[0] ?? "")]; + for (let i = 1; i < parts.length - 1; i++) { + submits.push(parts[i] ?? ""); + } + return { submits, buffer: (parts[parts.length - 1] ?? "").replace(/[\r\n]/g, "") }; +} diff --git a/src/repl/run.ts b/src/repl/run.ts new file mode 100644 index 0000000..caf48da --- /dev/null +++ b/src/repl/run.ts @@ -0,0 +1,260 @@ +/** + * REPL orchestration (bare `zcode-acp`): spawn the bridge subprocess, connect + * to it as an ACP client over stdio, create a session rooted at the current + * directory, and pump session updates into the Ink UI. + * + * State lives here (plain variables + full rerender) — the App component is a + * pure view over a snapshot. One process per conversation: closing the REPL + * closes the bridge (its stdin-end shutdown path), which reaps the zcode + * backend through the bridge's normal cleanup. + */ + +import { spawn } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import * as acp from "@agentclientprotocol/sdk"; +import type { ActiveSession } from "@agentclientprotocol/sdk"; +import { render } from "ink"; +import { createElement } from "react"; + +import { AGENT_INFO } from "../utils.js"; +import { App, type AppProps, type PermissionPrompt } from "./App.js"; +import { + applyUpdate, + createTurnState, + finishTurn, + parseCommand, + type ReplEntry, + type TurnState, +} from "./model.js"; + +export async function runRepl(): Promise { + const serverJs = fileURLToPath(new URL("../index.js", import.meta.url)); + const child = spawn(process.execPath, [serverJs], { + stdio: ["pipe", "pipe", "pipe"], + }); + // Bridge stderr (gated debug logs + warns) never interleaves with the Ink + // UI; keep a tail so a bridge crash can be explained on exit. + let stderrTail = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (d: string) => { + stderrTail = (stderrTail + d).slice(-2000); + }); + + // --- UI state (external store; every mutation rerenders a fresh snapshot) --- + let entries: ReplEntry[] = []; + let turn: TurnState | null = null; + let permission: PermissionPrompt | null = null; + let busy = true; + let exited = false; + // True between a user submit and the stop message. The bridge also pushes + // idle-time updates right after session/new (available_commands, mode + // state, …) — without this gate those would spin up a phantom turn and + // show the "running" spinner with nothing in flight. + let turnActive = false; + // Prompts submitted while another turn runs (or before the session is + // ready); drained one per stop, FIFO. + const promptQueue: string[] = []; + + const ink = render(createElement(App, snapshot()), { exitOnCtrlC: false }); + function snapshot(): AppProps { + return { + entries, + turn, + permission, + busy, + onSubmit: (text) => void onSubmit(text), + onCancelTurn, + onAnswerPermission: (id) => permissionResolver?.(id), + onExit: () => cleanup(0), + }; + } + function rerender(): void { + if (!exited) ink.rerender(createElement(App, snapshot())); + } + + // --- Permission bridging: agent request → picker → response --- + let permissionResolver: ((id: string | null) => void) | null = null; + + // --- ACP client connection --- + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin! as Writable), + Readable.toWeb(child.stdout!) as ReadableStream, + ); + const app = acp + .client({ name: "zcode-acp" }) + .onRequest("session/request_permission", async (ctx) => { + const id = await new Promise((resolve) => { + permissionResolver = resolve; + permission = { + title: ctx.params.toolCall?.title ?? "tool call", + options: ctx.params.options.map((o) => ({ id: o.optionId, name: o.name })), + }; + rerender(); + }); + permissionResolver = null; + permission = null; + rerender(); + return id === null + ? { outcome: { outcome: "cancelled" } } + : { outcome: { outcome: "selected", optionId: id } }; + }) + .connect(stream); + const cx = app.agent; + + let session: ActiveSession | null = null; + try { + session = await cx.buildSession(process.cwd()).start(); + } catch (err) { + entries = [ + ...entries, + { + kind: "note", + text: `failed to start session: ${err instanceof Error ? err.message : String(err)}`, + }, + ]; + rerender(); + cleanup(1); + return; + } + busy = false; + drainQueue(); + + // --- Update pump: fold notifications into the live turn, stop closes it --- + void (async () => { + for (;;) { + let msg; + try { + msg = await session!.nextUpdate(); + } catch { + return; // session disposed on exit + } + if (msg.kind === "stop") { + entries = [...entries, ...finishTurn(turn ?? createTurnState(), msg.response.stopReason)]; + turn = null; + drainQueue(); + } else if (turnActive) { + turn = applyUpdate(turn ?? createTurnState(), msg.update); + rerender(); + } + // Idle-time updates outside a user turn are dropped (v1 has no command + // palette or mode display to feed them into). + } + })(); + + async function onSubmit(text: string): Promise { + if (parseCommand(text) === "exit") { + cleanup(0); + return; + } + entries = [...entries, { kind: "user", text }]; + if (!session || turnActive) { + // Follow-ups while a turn is running (or the session is still starting) + // are queued; the stop handler drains them one at a time. Resetting the + // live turn here would discard its entries mid-stream. + promptQueue.push(text); + rerender(); + return; + } + await startTurn(text); + } + + async function startTurn(text: string): Promise { + turn = createTurnState(); + turnActive = true; + rerender(); + try { + await session!.prompt(text); + } catch (err) { + // The pump's stop message still flushes the turn; surface the error too. + // The queue is NOT drained here: racing a queued prompt against the + // pending stop would close that next turn prematurely. + entries = [ + ...entries, + { + kind: "note", + text: `prompt failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ]; + turn = null; + turnActive = false; + rerender(); + } + } + + /** Start the next queued prompt after a turn ends; go idle when empty. */ + function drainQueue(): void { + const next = promptQueue.shift(); + if (next === undefined) { + turnActive = false; + rerender(); + return; + } + void startTurn(next); + } + + function onCancelTurn(): void { + if (!session || !turn) return; + try { + void cx.notify("session/cancel", { sessionId: session.sessionId }); + } catch { + // best-effort; a second ctrl-c exits outright + } + } + + function cleanup(code: number): void { + if (exited) return; + exited = true; + // Each step is guarded: one throwing (e.g. unmount during a render) must + // not skip the child shutdown and leave the process hanging. + try { + session?.dispose(); + } catch { + // ignore + } + try { + ink.unmount(); + } catch { + // ignore + } + try { + // Graceful path: closing stdin triggers the bridge's own shutdown, + // which reaps the zcode backend group. + child.stdin?.end(); + } catch { + // ignore + } + setTimeout(() => child.kill("SIGKILL"), 3000).unref(); + // Hard fallback: if the bridge ignores stdin-close shutdown (or the exit + // event is lost), the REPL still exits instead of hanging forever. + setTimeout(() => process.exit(code), 5000); + child.once("exit", () => { + if (code !== 0 && stderrTail.trim()) { + process.stderr.write(`\nbridge stderr tail:\n${stderrTail}\n`); + } + process.exit(code); + }); + } + + // Bridge died under us (crash, backend failure) — report and exit hard. + child.once("exit", (code) => { + if (exited) return; + exited = true; + ink.unmount(); + process.stderr.write( + `bridge exited unexpectedly (code ${code})\n${stderrTail.trim() ? `stderr tail:\n${stderrTail}` : ""}\n`, + ); + process.exit(1); + }); + + // Welcome banner as the first transcript entry. + entries = [ + { + kind: "note", + text: `${AGENT_INFO.name} v${AGENT_INFO.version} — session ready (${process.cwd()}). /exit to quit · ctrl-c cancels a running turn · idle ctrl-c twice quits`, + }, + ]; + rerender(); +} diff --git a/tests/cli-quota.test.ts b/tests/cli-quota.test.ts index 74ab6b1..0727327 100644 --- a/tests/cli-quota.test.ts +++ b/tests/cli-quota.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the `zcode-quota` standalone CLI: argv parsing and interval + * Tests for the `zcode-acp quota` standalone CLI: argv parsing and interval * clamping. The watch loop itself is an I/O loop (sleep + redraw) and is left * to manual verification; only the pure helpers are unit-tested. * diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..281467d --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for the Unified CLI dispatcher (src/cli.ts). + * + * The module guards its top-level `main()` behind an `invokedDirectly` check + * (entry path matches cli.js/cli.ts or a bin symlink name), so importing it + * here does NOT start a subcommand. resolveInvocation is pure and exported + * for exactly this purpose. + */ + +import { describe, expect, it } from "vitest"; + +import { resolveInvocation } from "../src/cli.js"; + +describe("resolveInvocation", () => { + it("routes the legacy zcode-acp-server bin alias straight to server", () => { + // Editors spawn `zcode-acp-server` with no subcommand; the symlink lands + // here with argv[1] keeping the alias name. + expect(resolveInvocation("zcode-acp-server", [])).toEqual({ kind: "server" }); + // Even if extra args sneak in, the alias is not a subcommand parser. + expect(resolveInvocation("zcode-acp-server", ["--anything"])).toEqual({ kind: "server" }); + }); + + it("maps each subcommand, passing through trailing args", () => { + expect(resolveInvocation("cli.js", ["server"])).toEqual({ kind: "server" }); + expect(resolveInvocation("cli.js", ["hub"])).toEqual({ kind: "hub" }); + expect(resolveInvocation("cli.js", ["quota"])).toEqual({ kind: "quota", args: [] }); + expect(resolveInvocation("cli.js", ["quota", "-w", "glm"])).toEqual({ + kind: "quota", + args: ["-w", "glm"], + }); + }); + + it("treats bare invocation as the interactive REPL and help flags as help", () => { + // Bare is NOT explicit: without a TTY it falls back to the stdio server + // (Windows npm shims land there with the bin name lost from argv). + expect(resolveInvocation("cli.js", [])).toEqual({ kind: "repl", explicit: false }); + expect(resolveInvocation("cli.js", ["repl"])).toEqual({ kind: "repl", explicit: true }); + expect(resolveInvocation("cli.js", ["tui"])).toEqual({ kind: "repl", explicit: true }); + expect(resolveInvocation("cli.js", ["-h"])).toEqual({ kind: "help" }); + expect(resolveInvocation("cli.js", ["--help"])).toEqual({ kind: "help" }); + expect(resolveInvocation("cli.js", ["help"])).toEqual({ kind: "help" }); + }); + + it("reports unknown subcommands with the offending token", () => { + expect(resolveInvocation("cli.js", ["serve"])).toEqual({ kind: "unknown", sub: "serve" }); + // A bare prompt string is not a subcommand — it does not start a turn. + expect(resolveInvocation("cli.js", ["hello world"])).toEqual({ + kind: "unknown", + sub: "hello world", + }); + }); +}); diff --git a/tests/color.test.ts b/tests/color.test.ts index 7d0a685..8b89a82 100644 --- a/tests/color.test.ts +++ b/tests/color.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the 24-bit color progress bar used by the `zcode-quota` CLI's + * Tests for the 24-bit color progress bar used by the `zcode-acp quota` CLI's * default (heat) mode. The `/quota` slash command never touches this module — * it stays on the plain `renderBar`. */ diff --git a/tests/repl-model.test.ts b/tests/repl-model.test.ts new file mode 100644 index 0000000..251749a --- /dev/null +++ b/tests/repl-model.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for the REPL turn state machine (src/repl/model.ts) and the code + * fence colorizer (src/repl/App.tsx). + * + * The state machine folds raw ACP SessionUpdate notifications into render + * entries; these tests lock the chunk-ordering rules (thought flush on first + * message chunk, tool row upsert by toolCallId, stop-reason note) that the + * Ink view relies on. + */ + +import { describe, expect, it } from "vitest"; + +import { colorizeCodeFences } from "../src/repl/App.js"; +import { + applyUpdate, + createTurnState, + finishTurn, + parseCommand, + splitBulkInput, + type SessionUpdateLike, +} from "../src/repl/model.js"; + +function chunk( + kind: "agent_message_chunk" | "agent_thought_chunk", + text: string, +): SessionUpdateLike { + return { sessionUpdate: kind, content: { type: "text", text } } as SessionUpdateLike; +} + +describe("applyUpdate", () => { + it("buffers thought and message chunks separately", () => { + let s = createTurnState(); + s = applyUpdate(s, chunk("agent_thought_chunk", "thinking…")); + expect(s.thinkBuf).toBe("thinking…"); + expect(s.textBuf).toBe(""); + // A second thought chunk keeps accumulating before any prose starts. + s = applyUpdate(s, chunk("agent_thought_chunk", " more")); + expect(s.thinkBuf).toBe("thinking… more"); + }); + + it("flushes the thought buffer as an entry when prose starts", () => { + let s = createTurnState(); + s = applyUpdate(s, chunk("agent_thought_chunk", "plan the answer")); + s = applyUpdate(s, chunk("agent_message_chunk", "Here it is")); + expect(s.entries).toEqual([{ kind: "thinking", text: "plan the answer" }]); + expect(s.thinkBuf).toBe(""); + }); + + it("upserts tool rows by toolCallId and keeps last status", () => { + let s = createTurnState(); + s = applyUpdate(s, { + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "Read README.md", + status: "in_progress", + } as SessionUpdateLike); + s = applyUpdate(s, { + sessionUpdate: "tool_call_update", + toolCallId: "t1", + status: "completed", + } as SessionUpdateLike); + expect(s.entries).toEqual([ + { kind: "tool", id: "t1", title: "Read README.md", status: "completed" }, + ]); + }); + + it("renders a tool_call_update for an unseen call as a fresh row", () => { + const s = applyUpdate(createTurnState(), { + sessionUpdate: "tool_call_update", + toolCallId: "late", + title: "Late call", + status: "completed", + } as SessionUpdateLike); + expect(s.entries).toEqual([ + { kind: "tool", id: "late", title: "Late call", status: "completed" }, + ]); + }); +}); + +describe("finishTurn", () => { + it("flushes pending buffers and only notes non-end_turn stops", () => { + let s = createTurnState(); + s = applyUpdate(s, chunk("agent_thought_chunk", "hmm")); + s = applyUpdate(s, chunk("agent_message_chunk", "answer")); + const entries = finishTurn(s, "end_turn"); + expect(entries).toEqual([ + { kind: "thinking", text: "hmm" }, + { kind: "assistant", text: "answer" }, + ]); + }); + + it("adds a stopped note for cancelled turns", () => { + const entries = finishTurn(createTurnState(), "cancelled"); + expect(entries).toEqual([{ kind: "note", text: "stopped: cancelled" }]); + }); +}); + +describe("parseCommand", () => { + it("recognizes exit aliases and treats everything else as a prompt", () => { + expect(parseCommand("/exit")).toBe("exit"); + expect(parseCommand("/quit")).toBe("exit"); + expect(parseCommand("/q")).toBe("exit"); + expect(parseCommand("fix the bug")).toBe(null); + expect(parseCommand("")).toBe(null); + }); +}); + +describe("splitBulkInput", () => { + it("splits a multi-line paste into submits plus a trailing buffer", () => { + // Three embedded newlines: first line completes the in-progress value, + // the middle line must NOT be dropped, the last stays buffered. + expect(splitBulkInput("AB", "CD\r\nEF\r\nGH")).toEqual({ + submits: ["ABCD", "EF"], + buffer: "GH", + }); + }); + + it("submits the current line when the event ends with a newline", () => { + expect(splitBulkInput("", "one-shot\r")).toEqual({ submits: ["one-shot"], buffer: "" }); + expect(splitBulkInput("AB", "CD\n")).toEqual({ submits: ["ABCD"], buffer: "" }); + }); + + it("treats a lone newline as submitting the in-progress value", () => { + expect(splitBulkInput("AB", "\n")).toEqual({ submits: ["AB"], buffer: "" }); + }); +}); + +describe("colorizeCodeFences", () => { + it("leaves plain prose untouched and marks fenced lines", () => { + const out = colorizeCodeFences("intro\n```ts\nconst x = 1;\n```\nafter"); + // ANSI codes wrap the fenced region; plain lines stay identical. + expect(out).toContain("intro"); + expect(out).toContain("after"); + expect(out).toMatch(/const x = 1;/); + // The fence content is colorized (an ANSI escape sequence directly + // precedes it); avoid a literal escape in the regex to satisfy lint. + const idx = out.indexOf("const x = 1;"); + const esc = String.fromCharCode(27); + expect(out.slice(idx - 6, idx)).toContain(esc + "["); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5271828..fab4d17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "es2022", "module": "nodenext", + "jsx": "react-jsx", "moduleResolution": "nodenext", "rootDir": "src", "outDir": "dist",