From d1ca5f7a495a91da062e2e18986869b32794007d Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:39:49 +0000 Subject: [PATCH 1/9] feat(init): replace OpenTUI with Ink for the wizard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the OpenTUI implementation introduced in PR 4 for an Ink-based one. Same WizardUI surface, same store/store-mutators/file-tree, same sidebar layout (tip card + progress checklist + files-read tree) — just different render primitives. Why Ink? - No native bindings. OpenTUI's renderer is Zig-compiled and shipped as ~4.5 MB of platform-specific .so/.dylib/.dll files loaded via Bun's bun:ffi. The compiled CLI binary inlined that plus a ~6 MB JS bindings layer, costing ~10.7 MB. Ink is pure JS + React, dropping the binary by ~9.4 MB (118.23 → 108.79 MB). - No alternate-screen flicker. OpenTUI took over the whole terminal via the alternate-screen buffer; on dispose it wiped every trace of the run. We had to replay a stripped-down transcript to stderr so users had any scrollback. Ink renders inline, so log lines accumulate naturally and the user keeps everything in their terminal history. - Mature ecosystem. ink-spinner, ink-select-input, etc. cover most of what we hand-rolled in OpenTUI. Used by Wrangler, Gatsby, GitHub Copilot CLI, and others. Things that stayed the same: - WizardUI interface (banner / intro / log / spinner / select / multiselect / confirm / summary / cancel / outro / setStep / recordFilesReading / markFilesAnalyzed) - The external WizardStore + useSyncExternalStore subscription pattern (renamed from opentui-store.ts to wizard-store.ts) - file-tree.ts, sentry-tips.ts, types.ts (unchanged) - Sidebar layout: tip card (fixed 12 rows) on top, step checklist in the middle, files-read tree on the bottom - Step progress checklist with implicit-skip back-fill - Post-dispose chalk summary echoed to stderr after Ink unmounts Things that changed: - Sidebar tree window vs. scrollbox. Ink doesn't ship a scrollbox primitive. The files-read panel now shows the *last* N rows that fit, with a "… N earlier" hint when truncated. The tail-f UX (newly-read files always visible) comes for free since the panel re-renders to the bottom. - Multi-select. Built directly on Ink's useInput. ink-select- input doesn't expose a way to draw bracketed [✔] markers in addition to the cursor. - Cancellation. OpenTUI's keyHandler is global; Ink's useInput is per-component. Cancellation now hooks into process-level SIGINT (Ink's exitOnCtrlC: false lets us route Ctrl+C through our cooperative-cancel path instead of yanking the process). Bun-binary-only (same as OpenTUI was): - Ink's reconciler and yoga-layout use top-level await, which esbuild can't emit in our CJS npm bundle. So Ink is bundled into the Bun binary via the with-file import trick (same as OpenTUI used) but excluded from dist/index.cjs entirely. Node users continue to get LoggingUI — unchanged from before. - This preserves AGENTS.md's "no runtime dependencies" rule. bun run check:deps passes. Bun.compile workarounds (carried over from the OpenTUI fix in this PR series): - The with-file import keeps ink-app.tsx out of esbuild and Bun.compile's static bundle graph. Without this, Bun.compile mangles Ink's and React's CJS dev wrappers (it injects __promiseAll runtime helpers in positions the IIFEs can't parse, producing "SyntaxError: Unexpected identifier '__promiseAll'" at startup inside e.g. parse-keypress.js or react-jsx-runtime.development.js). - ?bridge=1 query string on the dynamic import bypasses Bun's module-cache collision between the file-resource import and the dynamic import of the same absolute path. Same workaround we landed earlier for OpenTUI. - define process.env.NODE_ENV=production on Bun.build forces React to use its production builds; the dev builds otherwise trigger the __promiseAll bug even via the embedded-file path. - react-devtools-core installed as a devDep so Bun.compile can resolve the static reference inside Ink's reconciler. The actual import is gated behind process.env.DEV === "true" so it's dead code in production. Files added: - src/lib/init/ui/ink-app.tsx — Ink React tree (renamed from opentui-app.tsx, fully rewritten for Ink primitives) - src/lib/init/ui/ink-ui.ts — InkUI bridge class (renamed from opentui-ui.ts, ported to Ink's render() API) Files renamed: - src/lib/init/ui/opentui-store.ts → wizard-store.ts (no logic changes — just docstring updates removing OpenTUI references) - test/lib/init/ui/opentui-store.test.ts → wizard-store.test.ts Files deleted: - src/lib/init/ui/opentui-app.tsx - src/lib/init/ui/opentui-ui.ts Dep changes: - REMOVED: @opentui/core, @opentui/react - ADDED: ink, ink-spinner, ink-select-input, ink-text-input, react-devtools-core (all devDependencies) Verification: - bun run typecheck (clean) - bun x ultracite check (1 pre-existing warning, no new ones) - bun test --isolate test/lib/init/ (227 pass) - bun run check:deps (no runtime dependencies) - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, -9.44 MB vs. OpenTUI's 118.23 MB) - SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, unchanged) - ./dist-bin/sentry-linux-x64 init --help (renders cleanly) - node ./dist/bin.cjs init --help (Node path renders cleanly) - Smoke test creating an InkUI and exercising every WizardUI method produced no React reconciler errors and a clean post-dispose summary. --- bun.lock | 263 ++---- package.json | 7 +- .../skills/sentry-cli/references/init.md | 2 +- script/build.ts | 62 +- script/bundle.ts | 48 +- script/text-import-plugin.ts | 26 +- src/commands/init.ts | 14 +- src/lib/init/ui/factory.ts | 51 +- src/lib/init/ui/ink-app.tsx | 835 +++++++++++++++++ src/lib/init/ui/{opentui-ui.ts => ink-ui.ts} | 334 +++---- src/lib/init/ui/opentui-app.tsx | 843 ------------------ src/lib/init/ui/types.ts | 7 +- .../ui/{opentui-store.ts => wizard-store.ts} | 11 +- test/lib/init/ui/factory.test.ts | 8 +- ...tui-store.test.ts => wizard-store.test.ts} | 8 +- tsconfig.json | 1 - 16 files changed, 1196 insertions(+), 1324 deletions(-) create mode 100644 src/lib/init/ui/ink-app.tsx rename src/lib/init/ui/{opentui-ui.ts => ink-ui.ts} (59%) delete mode 100644 src/lib/init/ui/opentui-app.tsx rename src/lib/init/ui/{opentui-store.ts => wizard-store.ts} (97%) rename test/lib/init/ui/{opentui-store.test.ts => wizard-store.test.ts} (95%) diff --git a/bun.lock b/bun.lock index b89140897..59741d96f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,6 @@ "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", "@mastra/client-js": "^1.4.0", - "@opentui/core": "^0.2.0", - "@opentui/react": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", @@ -30,6 +28,10 @@ "fast-check": "^4.5.3", "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", @@ -37,6 +39,7 @@ "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "react": "^19.2.5", + "react-devtools-core": "^7.0.1", "semver": "^7.7.3", "string-width": "^8.2.0", "tinyglobby": "^0.2.15", @@ -70,6 +73,8 @@ "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], @@ -94,8 +99,6 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -156,62 +159,6 @@ "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.4", "", {}, "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], - - "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], - - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], - - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], - - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], - - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], - - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], - - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], - - "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], - - "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], - - "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], - - "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], - - "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], - - "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], - - "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], - - "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], - - "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], - - "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], - - "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], - - "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], - - "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], - - "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], - - "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], - - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], - - "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], - - "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], - - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], - - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], @@ -234,22 +181,6 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], - "@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="], - - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], - - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], - - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], - - "@opentui/react": ["@opentui/react@0.2.0", "", { "dependencies": { "@opentui/core": "0.2.0", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-wXDpBoj3GQuQJG5MrIfyYRshU3bwaBYuSC6ThYiVHSDgt8PGhy2v2xPzFVvJZDSx7hp9gUaaNzWPsXIRLwrlCQ=="], - "@peggyjs/from-mem": ["@peggyjs/from-mem@3.1.3", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg=="], "@sentry/api": ["@sentry/api@0.113.0", "", {}, "sha512-28W0Oykb/O+6kH8F+OEd8070N4z7ctawlyUtEvnNZNlaLviDC9Is1X/0JiK2Xb9y2ZNbkWf+/H1y5hXr0WTIOw=="], @@ -276,8 +207,6 @@ "@stricli/core": ["@stricli/core@1.2.5", "", {}, "sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw=="], - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -320,8 +249,6 @@ "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], - "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -336,12 +263,12 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "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=="], - "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -350,32 +277,16 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "binpunch": ["binpunch@1.0.0", "", { "bin": { "binpunch": "dist/cli.js" } }, "sha512-ghxdoerLN3WN64kteDJuL4d9dy7gbvcqoADNRWBk6aQ5FrYH1EmPmREAdcdIdTNAA3uW3V38Env5OqH2lj+i+g=="], - "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - - "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], - - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], - - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], - - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], - - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "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=="], @@ -388,10 +299,20 @@ "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + "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=="], @@ -406,6 +327,8 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], @@ -426,18 +349,18 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], - "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "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=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -446,13 +369,15 @@ "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-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="], + "esbuild": ["esbuild@0.25.12", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -460,14 +385,10 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "express": ["express@4.22.1", "", { "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" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], @@ -482,7 +403,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "finalhandler": ["finalhandler@1.3.2", "", { "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" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], @@ -506,8 +427,6 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -536,36 +455,42 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "import-in-the-middle": ["import-in-the-middle@3.0.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@7.0.1", "", { "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" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w=="], + + "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "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=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], "is-network-error": ["is-network-error@1.3.1", "", {}, "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -594,12 +519,14 @@ "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -624,12 +551,12 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], @@ -638,14 +565,6 @@ "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - - "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], - - "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], - - "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], - "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], @@ -654,6 +573,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], @@ -662,26 +583,16 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "peggy": ["peggy@5.1.0", "", { "dependencies": { "@peggyjs/from-mem": "3.1.3", "commander": "^14.0.3", "source-map-generator": "2.0.6" }, "bin": { "peggy": "bin/peggy.js" } }, "sha512-IEo5aYRZ2kXH4Qby06cjtL114PZnwLoTiA41vUmg2vPZgANn+c87m5BUurhuDr5/cu758ZlpgsAfBVx+hhO5+w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], - - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], @@ -702,25 +613,21 @@ "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], - "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], - - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], @@ -748,50 +655,50 @@ "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=="], - "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "source-map-generator": ["source-map-generator@2.0.6", "", {}, "sha512-IlassDs1Ve8nV6uyQZXF9kdkJpVKnMte2JZQXu13M0A5zwc+vu6+LNHfmxsHBMDtoZE21RHiKI0/xvpecZRCNg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], - "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "trpc-cli": ["trpc-cli@0.12.2", "", { "dependencies": { "commander": "^14.0.0" }, "peerDependencies": { "@orpc/server": "^1.0.0", "@trpc/server": "^10.45.2 || ^11.0.1", "@valibot/to-json-schema": "^1.1.0", "effect": "^3.14.2 || ^4.0.0", "valibot": "^1.1.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["@orpc/server", "@trpc/server", "@valibot/to-json-schema", "effect", "valibot", "zod"], "bin": { "trpc-cli": "dist/bin.js" } }, "sha512-kGNCiyOimGlfcZFImbWzFF2Nn3TMnenwUdyuckiN5SEaceJbIac7+Iau3WsVHjQpoNgugFruZMDOKf8GNQNtJw=="], + "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -802,8 +709,6 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -814,26 +719,20 @@ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "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=="], - "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], - - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -866,11 +765,11 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], - "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@peggyjs/from-mem/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@opentui/core/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=="], + "@sindresorhus/slugify/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "@peggyjs/from-mem/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@sindresorhus/transliterate/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -890,32 +789,24 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], - "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "trpc-cli/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "ultracite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "zod-from-json-schema/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], @@ -940,11 +831,9 @@ "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "@opentui/core/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -964,7 +853,7 @@ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index e96f1651c..b450cb762 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,6 @@ "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", "@mastra/client-js": "^1.4.0", - "@opentui/core": "^0.2.0", - "@opentui/react": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", @@ -32,6 +30,10 @@ "fast-check": "^4.5.3", "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", @@ -39,6 +41,7 @@ "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", "react": "^19.2.5", + "react-devtools-core": "^7.0.1", "semver": "^7.7.3", "string-width": "^8.2.0", "tinyglobby": "^0.2.15", diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md index 2ee9cfd79..bff178e11 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -20,7 +20,7 @@ Initialize Sentry in your project (experimental) - `-n, --dry-run - Show what would happen without making changes` - `--features ... - Features to enable: errors,tracing,logs,replay,profiling,ai-monitoring,user-feedback` - `-t, --team - Team slug to create the project under` -- `--tui - Use the OpenTUI full-screen interface (default on the Bun binary). Pass --no-tui to disable.` +- `--tui - Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.` **Examples:** diff --git a/script/build.ts b/script/build.ts index d1ad7532e..44689f217 100644 --- a/script/build.ts +++ b/script/build.ts @@ -124,33 +124,28 @@ async function bundleJs(): Promise { platform: "node", target: "esnext", format: "esm", - // Externalize the OpenTUI + React stack from the esbuild - // bundling step. Two reasons: + // Externalize the Ink + React stack from the esbuild bundling + // step. `react`'s CJS jsx-runtime, when pulled into esbuild's + // `__commonJS` wrappers and re-bundled by Bun.compile, produces + // malformed output containing a TDZ `init_react` symbol + // embedded in the wrong scope. Keeping React (and its + // consumers) external lets Bun's runtime resolve them fresh at + // first invocation, outside the buggy bundler path. // - // 1. `@opentui/core` ships Bun-specific - // `import "..." with { type: "file" }` syntax for - // tree-sitter assets (`*.scm`, `*.wasm`) that esbuild - // doesn't understand. Bun.compile downstream resolves - // them natively and embeds the assets into the binary. - // - // 2. `react`'s CJS jsx-runtime, when pulled into esbuild's - // `__commonJS` wrappers and re-bundled by Bun.compile, - // produces malformed output containing a TDZ - // `init_react` symbol embedded in the wrong scope. We - // sidestep this by keeping React out of esbuild AND - // reaching it only through the embedded `opentui-app.tsx` - // asset (see `src/lib/init/ui/opentui-ui.ts`'s - // `with { type: "file" }` import) — Bun's runtime - // resolves React fresh at first invocation, outside the - // buggy bundler path. + // The npm bundle (`script/bundle.ts`) externalizes the same + // packages for the same reason — bundling Ink's React tree + // through esbuild produces a CJS wrapper that hits a TDZ at + // runtime when React is first touched. external: [ "bun:*", - "@opentui/core", - "@opentui/core/*", - "@opentui/react", - "@opentui/react/*", + "ink", + "ink-spinner", + "ink-select-input", + "ink-text-input", "react", "react/*", + "react-reconciler", + "react-reconciler/*", ], sourcemap: "linked", // Minify syntax and whitespace but NOT identifiers. Bun.build @@ -322,6 +317,25 @@ async function compileTarget(target: BuildTarget): Promise { try { const result = await Bun.build({ entrypoints: [BUNDLE_JS], + // Force React to load its production builds. React's CJS + // entry switches at runtime via + // `if (process.env.NODE_ENV === "production")` + // — leaving NODE_ENV unset would drag in the development + // builds, whose CJS wrappers Bun.compile can't bundle cleanly + // (it injects `__promiseAll` runtime helpers in positions the + // dev-build's IIFE doesn't tolerate, causing a SyntaxError at + // startup). Production builds parse fine. + // + // `react-devtools-core` is gated behind `process.env.DEV === + // "true"` inside Ink's reconciler — never reached in our + // production binary. We still install it as a devDep so + // Bun.compile can resolve the static `import devtools from + // "react-devtools-core"` reference; without it the build + // fails with "Could not resolve". The inlined module gets + // dead-code-eliminated by the DEV gate at runtime. + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, compile: { target: getBunTarget(target) as | "bun-darwin-arm64" @@ -508,11 +522,11 @@ async function build(): Promise { await uploadSourcemapToSentry(); // Clean up intermediate bundle (only the binaries are artifacts). - // The `opentui-app.tsx` copy comes from the text-import-plugin's + // The `ink-app.tsx` copy comes from the text-import-plugin's // `with { type: "file" }` handling — it gets embedded into the // compiled binary, so the sidecar copy is no longer needed once // every target has compiled. - await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/opentui-app.tsx`; + await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/ink-app.tsx`; // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/bundle.ts b/script/bundle.ts index c88eaba69..aecf391b6 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -215,22 +215,31 @@ const result = await build({ // Replace import.meta.url with the injected shim variable for CJS "import.meta.url": "import_meta_url", }, - // Externalize Node.js built-ins, plus the OpenTUI + React stack. - // OpenTUI ships native Zig bindings that only load under the Bun - // runtime, so the npm/Node distribution must NOT bundle them. The - // factory in `src/lib/init/ui/factory.ts` lazy-imports the OpenTUI - // path and falls back to LoggingUI on import failure, so marking - // these external means a Node user simply gets the non-TUI flow - // without a crash. The Bun compile (`script/build.ts`) bundles - // them into the native binary, where the loader is available. + // Externalize Node.js built-ins, plus Ink + React + companions. + // Ink uses top-level await (in `node_modules/ink/build/reconciler.js` + // and `yoga-layout/dist/src/index.js`) which esbuild can't emit in + // a CJS bundle, so the packages must stay external for the + // npm/Node distribution. The factory in `factory.ts` lazy-imports + // the Ink path via `with { type: "file" }` and falls back to + // `LoggingUI` on import failure, so a Node user without Ink + // installed simply gets the non-TUI flow without a crash. + // + // The Bun compile (`script/build.ts`) embeds `ink-app.tsx` as a + // file resource — at runtime Bun's loader resolves Ink + React + // fresh, sidestepping the same CJS-wrapping bug that'd hit if + // these were bundled into the binary's pre-compiled JS. external: [ "node:*", - "@opentui/core", - "@opentui/core/*", - "@opentui/react", - "@opentui/react/*", + "ink", + "ink-spinner", + "ink-select-input", + "ink-text-input", "react", "react/*", + "react-reconciler", + "react-reconciler/*", + "react-devtools-core", + "yoga-layout", ], metafile: true, plugins, @@ -293,15 +302,16 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS); console.log(" -> dist/bin.cjs (CLI wrapper)"); console.log(" -> dist/index.d.cts (type declarations)"); -// Clean up the `opentui-app.tsx` sidecar that the text-import-plugin +// Clean up the `ink-app.tsx` sidecar that the text-import-plugin // drops into `dist/` when it sees the `with { type: "file" }` import -// in `src/lib/init/ui/opentui-ui.ts`. The npm distribution doesn't -// run the OpenTuiUI factory at all (it's gated to the Bun binary), -// so the sidecar is unused — and it's not in `package.json#files` -// either, so it wouldn't ship even without this cleanup. Removing -// it just keeps the local `dist/` directory tidy. +// in `src/lib/init/ui/ink-ui.ts`. The npm distribution doesn't run +// the InkUI factory at all (it's gated to the Bun binary because +// Ink uses top-level await that we can't bundle into CJS), so the +// sidecar is unused — and it's not in `package.json#files` either, +// so it wouldn't ship even without this cleanup. Removing it just +// keeps the local `dist/` directory tidy. try { - await unlink("./dist/opentui-app.tsx"); + await unlink("./dist/ink-app.tsx"); } catch { // Sidecar may not exist (e.g. plugin path not exercised) — fine. } diff --git a/script/text-import-plugin.ts b/script/text-import-plugin.ts index ea6c81148..5d36df85b 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -15,17 +15,10 @@ * runtime. * * Used by `script/build.ts` (single-file executable) and - * `script/bundle.ts` (CJS library bundle) so: - * - * 1. The grep-worker source in `src/lib/scan/worker-pool.ts` loads - * correctly in both dev and compiled builds (`text` branch). - * 2. `src/lib/init/ui/opentui-app.tsx` ships embedded into the - * Bun binary as a file resource (`file` branch). `OpenTuiUI` - * then `await import(path)`s it at runtime, sidestepping a Bun - * bundler bug that mangles React's CJS jsx-runtime wrapping - * when reached through static imports inside `__commonJS` - * scope. Embedding the .tsx as raw bytes pushes resolution to - * Bun's runtime (not bundler), which doesn't have the bug. + * `script/bundle.ts` (CJS library bundle) so the grep-worker source + * in `src/lib/scan/worker-pool.ts` loads correctly in both dev and + * compiled builds (`text` branch). The `file` branch is kept for + * future use; today no source file goes through it. */ import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; @@ -54,14 +47,9 @@ export const textImportPlugin: Plugin = { // Bun.compile resolves imports relative to the bundle file's // directory at compile time, not the original source. // - // The npm bundle path (`script/bundle.ts`) also reaches this - // branch — `opentui-ui.ts` has the import at module top — - // but `@opentui/*` and `react` are externalized there, so - // the OpenTuiUI factory never runs and the embedded copy is - // unused at runtime. We still produce it because esbuild - // resolves all reachable imports regardless of whether they - // execute. The `mkdirSync` below guards against the - // bundle's `outdir` not yet existing when the plugin fires. + // `mkdirSync` guards against the bundle's `outdir` not yet + // existing when the plugin fires — esbuild creates the + // outdir lazily on first write. const sourcePath = resolvePath(args.resolveDir, args.path); const outdir = build.initialOptions.outdir ? resolvePath(build.initialOptions.outdir) diff --git a/src/commands/init.ts b/src/commands/init.ts index 3b4fd95b3..3221c60a9 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -46,11 +46,13 @@ type InitFlags = { readonly features?: string[]; readonly team?: string; /** - * Default `true` (OpenTUI is the default UI). Stricli auto-generates - * a negated `--no-tui` flag that flips this to `false` — that's the - * escape hatch users invoke when the OpenTUI path misbehaves. The - * positive `--tui` flag is also accepted for symmetry but is a no-op - * versus the default. + * Default `true` (Ink is the default UI on the Bun binary). Stricli + * auto-generates a negated `--no-tui` flag that flips this to + * `false` — that's the escape hatch users invoke when the Ink path + * misbehaves (e.g. on unusual terminal emulators). The positive + * `--tui` flag is also accepted for symmetry but is a no-op versus + * the default. On the npm/Node distribution this flag has no + * effect; the factory always picks `LoggingUI` there. */ readonly tui: boolean; }; @@ -237,7 +239,7 @@ export const initCommand = buildCommand< tui: { kind: "boolean", brief: - "Use the OpenTUI full-screen interface (default on the Bun binary). Pass --no-tui to disable.", + "Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.", default: true, }, }, diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts index 01af74da9..ff363d390 100644 --- a/src/lib/init/ui/factory.ts +++ b/src/lib/init/ui/factory.ts @@ -12,20 +12,25 @@ * (CI / piped input). Prompt methods throw, so callers must * pre-resolve every choice up-front. * 2. `SENTRY_INIT_TUI=0` or `--no-tui` — `LoggingUI`. Acts as a debug - * escape hatch when the OpenTUI path misbehaves. In an interactive + * escape hatch when the Ink path misbehaves. In an interactive * context this means the wizard becomes effectively non-interactive * (any prompt aborts), so users hitting this path will need to set * every choice via flags or rely on auto-detection. * 3. Running outside the Bun-compiled binary (i.e. on Node) — also - * `LoggingUI`. OpenTUI ships native Zig bindings that the npm - * `dist/bin.cjs` distribution can't load. The npm package's - * `--help` output and onboarding docs direct users to the Bun - * binary for the interactive `sentry init` experience. - * 4. Default (Bun binary, interactive, no opt-out) — `OpenTuiUI`. + * `LoggingUI`. Ink uses top-level await in its reconciler and the + * `yoga-layout` dependency, which esbuild can't emit in our CJS + * bundle, so the npm distribution can't load Ink at runtime. The + * Bun binary embeds Ink + React + ink-app.tsx via + * `with { type: "file" }`, sidestepping the bundler entirely. The + * npm package's `--help` output and onboarding docs direct users + * to the Bun binary for the interactive `sentry init` experience. + * 4. Default (Bun binary, interactive, no opt-out) — `InkUI`. * - * The previous `ClackUI` implementation was removed in PR 4 once the - * OpenTUI implementation became the default. `@clack/prompts` is no - * longer a dependency. + * Implementation history: + * - PR 4: replaced `ClackUI` with `OpenTuiUI` as the default. + * - This PR: replaced `OpenTuiUI` with `InkUI`. OpenTUI's Zig + * bindings added ~10.7 MB to the binary; Ink + React + companions + * add a fraction of that and use no native code. */ import { LoggingUI } from "./logging-ui.js"; @@ -47,8 +52,9 @@ export type UIFactoryOptions = { /** * Detect whether the CLI is running inside the Bun-compiled binary - * (where OpenTUI's native bindings are present) vs. the npm/Node - * distribution. The `Bun` global only exists in the Bun runtime. + * (where the embedded `ink-app.tsx` resource is reachable) vs. the + * npm/Node distribution. The `Bun` global only exists in the Bun + * runtime. * * Exported for the test suite — production callers should go through * `getUIAsync()`. @@ -74,7 +80,7 @@ export function isInteractiveTerminal(): boolean { /** * Returns `true` when the `LoggingUI` should be used — i.e. we're in * a non-interactive context, the user opted out of the TUI, the env - * var override is set, or the runtime can't load OpenTUI. + * var override is set, or the runtime can't load Ink. */ function shouldUseLogging(opts: UIFactoryOptions): boolean { if (process.env.SENTRY_INIT_TUI === "0") { @@ -96,11 +102,11 @@ function shouldUseLogging(opts: UIFactoryOptions): boolean { } /** - * Async factory — picks `OpenTuiUI` for interactive runs on the Bun + * Async factory — picks `InkUI` for interactive runs on the Bun * binary, otherwise `LoggingUI`. The async form exists because - * instantiating `OpenTuiUI` requires a lazy `import("@opentui/core")` - * (the package isn't bundled into the npm/Node distribution and would - * crash if statically imported there). + * instantiating `InkUI` requires a lazy `import("ink")` (the package + * isn't bundled into the npm/Node distribution and would fail to + * resolve if statically imported there). * * Callers should treat the return value as an `AsyncDisposable` and * use `await using ui = await getUIAsync(...)` to guarantee teardown @@ -111,13 +117,14 @@ export async function getUIAsync(opts: UIFactoryOptions): Promise { return new LoggingUI(); } try { - const { createOpenTuiUI } = await import("./opentui-ui.js"); - return await createOpenTuiUI(); + const { createInkUI } = await import("./ink-ui.js"); + return await createInkUI(); } catch { - // Fall through to LoggingUI so a missing/broken native binding - // doesn't take down the wizard. This branch is unreachable on a - // correctly built Bun binary — it exists as a safety net for - // unusual runtime environments where the import fails. + // Fall through to LoggingUI so a missing/broken Ink install + // doesn't take down the wizard. This branch should be + // unreachable on a correctly built Bun binary — it exists as + // a safety net for unusual runtime environments where the + // import fails. return new LoggingUI(); } } diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx new file mode 100644 index 000000000..2046c779e --- /dev/null +++ b/src/lib/init/ui/ink-app.tsx @@ -0,0 +1,835 @@ +/** + * InkUI React App + * + * Renders the wizard layout using Ink (React for CLIs). The component + * subscribes to a `WizardStore` (see `wizard-store.ts`) via + * `useSyncExternalStore` so imperative `WizardUI` method calls + * (`log.info`, `spinner.start`, etc.) trigger React re-renders without + * React state being the source of truth. + * + * Layout (left-aligned columns from outer chrome inwards): + * + * ┌─ sentry init ──────────────────────────────────────────────────┐ + * │ banner (ASCII) ╭ Did you know? ─────────╮ │ + * │ ──────────── │ │ │ + * │ ● log line │ │ │ + * │ ▲ log line │ Tip 3 of 12 │ │ + * │ ◐ spinner... ╰────────────────────────╯ │ + * │ ╭ Progress (n/m) ────────╮ │ + * │ │ ✓ Analyzing project │ │ + * │ │ ▶ Setting up project │ │ + * │ ╰────────────────────────╯ │ + * │ ╭ Files analyzed (n/m) ──╮ │ + * │ │ ◐ src/ │ │ + * │ │ ✓ package.json │ │ + * │ ╰────────────────────────╯ │ + * │ │ + * └─────────────────────────────────────────────────────────────────┘ + * + * Why an external store rather than React state owned by the App? + * The `WizardUI` interface is imperative (the wizard runner calls + * `ui.log.info(...)` from a generator). Threading those calls through + * React's state setters from outside React would require keeping a + * mutable reference to a setter that gets bound on first render — + * fragile, especially with concurrent mode. An external store keeps + * the imperative side decoupled from React's lifecycle. + * + * Differences from the previous OpenTUI implementation: + * - Ink renders to stdout incrementally (no alternate-screen + * buffer), so log lines naturally accumulate and get committed to + * scrollback as the wizard runs. No post-dispose stderr replay + * needed. + * - No `` primitive — the files-read panel windows the + * last N rows that fit. Tail-`f` UX comes for free since the + * panel re-renders to the bottom of the most-recent reads. + * - Multi-select uses Ink's `useInput` directly (no third-party + * multi-select component). Single-select uses `ink-select-input`. + */ + +import { Box, Text, useInput, useStdout } from "ink"; +import SelectInput from "ink-select-input"; +import Spinner from "ink-spinner"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { + buildFileTree, + buildReadTree, + type FileTreeRow, + flattenTree, +} from "./file-tree.js"; +import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; +import type { WizardSummary } from "./types.js"; +import type { + ActivePrompt, + FileReadEntry, + LogEntry, + LogSeverity, + SpinnerState, + StepEntry, + WizardStore, +} from "./wizard-store.js"; + +// ──────────────────────────── Visual constants ──────────────────────── + +const ACCENT = "magenta"; +const MUTED = "gray"; + +const COLOR_INFO = "cyan"; +const COLOR_WARN = "yellow"; +const COLOR_ERROR = "red"; +const COLOR_SUCCESS = "green"; + +/** Splits a path on either Unix or Windows separators. Pre-compiled + * to satisfy biome's `useTopLevelRegex` lint rule. + */ +const PATH_SEPARATOR_RE = /[\\/]/; + +const ICON_BY_SEVERITY: Record = + { + info: { glyph: "●", color: COLOR_INFO }, + warn: { glyph: "▲", color: COLOR_WARN }, + error: { glyph: "✖", color: COLOR_ERROR }, + success: { glyph: "✔", color: COLOR_SUCCESS }, + message: { glyph: " ", color: "white" }, + }; + +// ────────────────────────────── App entry ───────────────────────────── + +export type AppProps = { + store: WizardStore; +}; + +/** + * Width of the sidebar's outer box. Used both as `width` on the box + * and as part of the minimum-terminal-width threshold below which we + * hide the sidebar. + */ +const SIDEBAR_WIDTH = 36; + +/** + * Minimum terminal columns required to show the sidebar alongside the + * main column. Below this we drop the sidebar entirely so the banner, + * log lines, and prompts get the full row width. + * + * Reasoning: the banner is ~55 chars, the outer chrome eats 4 cols + * (border + padding), the inner column gap is 2, plus 36 cols for + * the sidebar → 97. We round up to 100 for breathing room. + */ +const SIDEBAR_BREAKPOINT = 100; + +/** + * Maximum number of files-read rows shown in the sidebar at once. + * Falls back to a windowed tail when the tree has more entries — + * Ink doesn't have a built-in scrollbox, but the tail-f UX (last N + * rows visible) is what the panel needs for an active read sequence. + * + * Sized to leave room for the tip card + progress checklist on a + * 24-row terminal: + * + * 24 rows total + * - 7 rows banner + divider + * - 12 rows tip card (fixed) + * - 9 rows progress (max visible steps) + * - 4 rows border + padding for the files panel itself + * = 8 rows available for file rows. We allow 12 on taller + * terminals via the dynamic resize hook below. + */ +const MIN_FILE_ROWS = 4; +const MAX_FILE_ROWS = 14; + +/** + * Root component. Subscribes to the store once at the top, then drills + * the snapshot fields into individual presentational components. + * + * The sidebar auto-hides on narrow terminals (see `SIDEBAR_BREAKPOINT`) + * — `useStdout()` exposes the live `columns` value so resizing flips + * the layout on the next render. + */ +export function App({ store }: AppProps): React.ReactNode { + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot + ); + const { columns, rows } = useTerminalSize(); + const showSidebar = columns >= SIDEBAR_BREAKPOINT; + + return ( + + + + {showSidebar ? ( + + ) : null} + + + ); +} + +/** + * Reactive accessor for terminal dimensions. Ink exposes the current + * stdout via `useStdout()` and emits `resize` on the wrapped stream; + * we read `columns`/`rows` once and then update on resize. + * + * Defaults to 80x24 if Ink couldn't infer dimensions (e.g. when piped + * through a non-TTY for a test) — those numbers keep the sidebar + * hidden, which is the safer fallback. + */ +function useTerminalSize(): { columns: number; rows: number } { + const { stdout } = useStdout(); + const [size, setSize] = useState(() => ({ + columns: stdout?.columns ?? 80, + rows: stdout?.rows ?? 24, + })); + useEffect(() => { + if (!stdout) { + return; + } + const onResize = () => { + setSize({ + columns: stdout.columns ?? 80, + rows: stdout.rows ?? 24, + }); + }; + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + return size; +} + +// ──────────────────────────── Main column ───────────────────────────── + +type MainColumnProps = { + bannerRows: { content: string; color: string }[]; + filesRead: FileReadEntry[]; + logs: LogEntry[]; + spinner: SpinnerState; + prompt: ActivePrompt | null; + summary: WizardSummary | null; + /** + * Whether to render the inline file-read status row above the + * spinner. We only show this when the sidebar is hidden (narrow + * terminals); otherwise the sidebar's `FilesPanel` gives a richer + * tree view and the inline row would be a noisy duplicate. + */ + showFileReadInline: boolean; +}; + +function MainColumn({ + bannerRows, + filesRead, + logs, + spinner, + prompt, + summary, + showFileReadInline, +}: MainColumnProps): React.ReactNode { + // Hide the file-read status once the wizard finishes — the summary + // panel is the canonical "what happened" surface at that point, and + // a stale "47 files analyzed" line below it would just be noise. + const showFileStatus = showFileReadInline && !summary && filesRead.length > 0; + return ( + +
+ + + {logs.map((log) => ( + + ))} + + {showFileStatus ? : null} + {spinner.active ? : null} + {summary ? : null} + {prompt ? : null} + + ); +} + +function Header({ + bannerRows, +}: { + bannerRows: { content: string; color: string }[]; +}): React.ReactNode { + return ( + + {bannerRows.map((row, i) => ( + // ASCII banner rows are positional, stable, and never re-ordered — + // the index key is correct here. + // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows + + {row.content} + + ))} + + ); +} + +function Divider(): React.ReactNode { + return ( + + {"─".repeat(50)} + + ); +} + +function LogLine({ entry }: { entry: LogEntry }): React.ReactNode { + const { glyph, color } = ICON_BY_SEVERITY[entry.severity]; + return ( + + + {glyph} + + {entry.text} + + ); +} + +function SpinnerRow({ state }: { state: SpinnerState }): React.ReactNode { + return ( + + + + + + + {state.message} + + ); +} + +/** + * Single-line file-read status, shown above the spinner ONLY when the + * sidebar is hidden (narrow terminals). The richer tree view in the + * sidebar's `FilesPanel` supersedes this when there's room. + * + * Rendering rules: + * - If any file is currently `reading`: show a yellow ● glyph plus + * up to two recent basenames and the running counter. + * - Otherwise: collapse to a green ✔ recap. + */ +function FileReadStatus({ + filesRead, +}: { + filesRead: FileReadEntry[]; +}): React.ReactNode { + const reading = filesRead.filter((entry) => entry.status === "reading"); + const analyzed = filesRead.length - reading.length; + + if (reading.length > 0) { + const recent = reading + .slice(-2) + .map((entry) => entry.path.split(PATH_SEPARATOR_RE).at(-1) ?? entry.path); + const overflow = reading.length - recent.length; + const namesPart = + overflow > 0 + ? `${recent.join(", ")} + ${overflow} more` + : recent.join(", "); + return ( + + + + + + Reading {namesPart} + + + {analyzed}/{filesRead.length} analyzed + + + ); + } + + return ( + + + + + + Analyzed {analyzed} {analyzed === 1 ? "file" : "files"} + + + ); +} + +// ────────────────────────────── Summary ─────────────────────────────── + +/** + * Compact summary panel rendered after the workflow finishes. Each + * field is a single row: small dim label cell followed by the value. + * Changed-files render as a tree below the field list. + */ +function SummaryPanel({ + summary, +}: { + summary: WizardSummary; +}): React.ReactNode { + return ( + + {summary.fields.length > 0 ? ( + + {summary.fields.map((field) => ( + + + {field.label} + + {field.value} + + ))} + + ) : null} + {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( + + ) : null} + + ); +} + +/** + * Render the changed-files list as a nested directory tree. + * Tree-shape computation lives in `file-tree.ts`; this component is + * purely presentational. + */ +function ChangedFilesTree({ + files, +}: { + files: { action: string; path: string }[]; +}): React.ReactNode { + const tree = buildFileTree(files); + const rows = flattenTree(tree); + return ( + + Changed files + {rows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional tree rows + + ))} + + ); +} + +function FileTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { + if (row.kind === "directory") { + return ( + + {`${row.prefix}${row.branch} `} + {row.label} + + ); + } + const { glyph, color } = changedFileStyle(row.action ?? "modify"); + return ( + + {`${row.prefix}${row.branch} `} + {`${glyph} `} + {row.label} + + ); +} + +function changedFileStyle(action: string): { glyph: string; color: string } { + if (action === "create") { + return { glyph: "+", color: COLOR_SUCCESS }; + } + if (action === "delete") { + return { glyph: "−", color: COLOR_ERROR }; + } + return { glyph: "~", color: COLOR_WARN }; +} + +// ─────────────────────────────── Prompts ────────────────────────────── + +function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { + if (prompt.kind === "select") { + return ; + } + return ; +} + +function SelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + // Build the items array once per options change. `ink-select-input` + // keys items by `key` (or falls back to `value`) so we explicitly + // set `key` to dodge a duplicate-key warning when two options share + // a label but differ in `value`. + const items = prompt.options.map((option) => ({ + key: option.value, + label: option.hint ? `${option.label} ${option.hint}` : option.label, + value: option.value, + })); + return ( + + {prompt.message} + { + prompt.resolve(String(item.value)); + }} + /> + + ); +} + +/** + * Multi-select uses local state to track the toggled values plus the + * currently-highlighted row. On every keystroke `useInput` runs: + * - up/down → move the cursor + * - space → flip the highlighted option in the selection set + * - enter → commit the current selection + * + * We render the list manually rather than reusing `ink-select-input` + * because that component doesn't expose a way to draw bracketed + * `[✔]` markers for selected items in addition to the cursor. + */ +function MultiSelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + const [selected, setSelected] = useState>( + () => new Set(prompt.initialSelected) + ); + const [highlighted, setHighlighted] = useState(0); + const totalCount = prompt.options.length; + + useInput((input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + if (key.downArrow) { + setHighlighted((idx) => (idx + 1) % totalCount); + return; + } + if (input === " ") { + const current = prompt.options[highlighted]; + if (!current) { + return; + } + setSelected((prev) => { + const next = new Set(prev); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + return next; + }); + return; + } + if (key.return) { + if (prompt.required && selected.size === 0) { + return; + } + // Preserve source option order in the returned array. + const ordered = prompt.options + .map((option) => option.value) + .filter((value) => selected.has(value)); + prompt.resolve(ordered); + } + }); + + return ( + + {prompt.message} + + space toggle · enter confirm · esc cancel + + {selected.size}/{totalCount} selected + + + + {prompt.options.map((option, idx) => { + const isSelected = selected.has(option.value); + const isCursor = idx === highlighted; + let marker = "[ ]"; + let markerColor = MUTED; + if (isSelected) { + marker = "[✔]"; + markerColor = COLOR_SUCCESS; + } + let cursor = " "; + if (isCursor) { + cursor = "›"; + } + return ( + + + {cursor} + + {marker} + {option.label} + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + })} + + + ); +} + +// ────────────────────────────── Sidebar ─────────────────────────────── + +/** + * The sidebar stacks three panels top-to-bottom: + * + * 1. {@link TipPanel} — fixed height, pinned. Can never be + * squashed by the panels below. + * 2. {@link ProgressPanel} — auto height, one row per visible step. + * 3. {@link FilesPanel} — windowed tail of the read-files tree. + * + * On narrow terminals (`columns < SIDEBAR_BREAKPOINT`) the parent + * App hides the whole sidebar; the inline `FileReadStatus` line in + * `MainColumn` takes over the file-read indicator role. + */ +function Sidebar({ + tipIndex, + steps, + filesRead, + terminalRows, +}: { + tipIndex: number; + steps: StepEntry[]; + filesRead: FileReadEntry[]; + terminalRows: number; +}): React.ReactNode { + // Reserve space for the tip card (12 rows incl. border + padding) + // and the progress checklist (steps + 4 rows of border + title). + // Whatever remains, capped at MAX_FILE_ROWS, goes to the files panel. + const tipReserved = 12; + const progressReserved = steps.length + 4; + const fileBudget = Math.max( + MIN_FILE_ROWS, + Math.min(MAX_FILE_ROWS, terminalRows - tipReserved - progressReserved - 2) + ); + return ( + + + + + + ); +} + +function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { + const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; + const total = SENTRY_TIPS.length; + const oneIndexed = (tipIndex % total) + 1; + return ( + + + + Did you know? + + + {tip.title} + + {tip.body} + + + + Tip {oneIndexed} of {total} + + + + ); +} + +/** + * Static checklist of workflow steps. Each row reflects a + * `StepEntry.status`: + * + * - `pending` — muted ◯ + * - `in_progress` — accent ▶ + * - `completed` — success ✓ + * - `skipped` — muted ◌ (lighter than pending) + * - `failed` — error ✖ + */ +function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { + const completedCount = steps.filter( + (entry) => entry.status === "completed" + ).length; + const totalCount = steps.length; + return ( + + + + Progress ({completedCount}/{totalCount}) + + + {steps.map((entry) => ( + + ))} + + ); +} + +function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { + const { glyph, glyphColor, label } = progressStyle(entry); + return ( + + + {glyph} + + {entry.label} + + ); +} + +function progressStyle(entry: StepEntry): { + glyph: string; + glyphColor: string; + label: string; +} { + if (entry.status === "in_progress") { + return { glyph: "▶", glyphColor: ACCENT, label: "white" }; + } + if (entry.status === "completed") { + return { glyph: "✓", glyphColor: COLOR_SUCCESS, label: MUTED }; + } + if (entry.status === "failed") { + return { glyph: "✖", glyphColor: COLOR_ERROR, label: COLOR_ERROR }; + } + if (entry.status === "skipped") { + return { glyph: "◌", glyphColor: MUTED, label: MUTED }; + } + // pending + return { glyph: "◯", glyphColor: MUTED, label: MUTED }; +} + +/** + * Read-files tree. Ink doesn't have a scrollbox primitive, so when + * the tree exceeds `maxRows` we render the **last** N rows (a + * tail-`f`-style window). For most runs the tree fits without + * truncation; long analyze sequences just push older entries off + * the top while keeping the active reads visible. + * + * Visual rules: + * - Directories: muted gray box-drawing branches + name with `/`. + * - Active reads (`status === "reading"`): magenta `◐` glyph, + * normal-color filename. The eye picks these out instantly. + * - Analyzed (`status === "analyzed"`): green `✓` glyph, dimmed + * filename. Done work recedes; in-flight work pops. + * + * Hidden until at least one file has been recorded — the empty box + * would just be visual noise during the auth/discover phase. + */ +function FilesPanel({ + filesRead, + maxRows, +}: { + filesRead: FileReadEntry[]; + maxRows: number; +}): React.ReactNode { + if (filesRead.length === 0) { + return null; + } + const tree = buildReadTree(filesRead); + const rows = flattenTree(tree); + const truncated = rows.length > maxRows; + const visible = truncated ? rows.slice(rows.length - maxRows) : rows; + const analyzedCount = filesRead.filter( + (entry) => entry.status === "analyzed" + ).length; + return ( + + + + Files analyzed ({analyzedCount}/{filesRead.length}) + + + {truncated ? ( + … {rows.length - maxRows} earlier + ) : null} + {visible.map((row, i) => ( + // Tree rows are positionally stable for a given filesRead + // snapshot — `buildReadTree` walks `filesRead` in insertion + // order and never reorders, so the index makes a fine key. + // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows + + ))} + + ); +} + +function ReadTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { + if (row.kind === "directory") { + return ( + + {`${row.prefix}${row.branch} `} + {row.label} + + ); + } + const { glyph, glyphColor, labelColor } = readStatusStyle(row.status); + return ( + + {`${row.prefix}${row.branch} `} + {`${glyph} `} + {row.label} + + ); +} + +function readStatusStyle(status: FileTreeRow["status"]): { + glyph: string; + glyphColor: string; + labelColor: string; +} { + if (status === "reading") { + return { glyph: "◐", glyphColor: ACCENT, labelColor: "white" }; + } + return { glyph: "✓", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; +} diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/ink-ui.ts similarity index 59% rename from src/lib/init/ui/opentui-ui.ts rename to src/lib/init/ui/ink-ui.ts index 7e22cb082..2c2b52c1a 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -1,52 +1,41 @@ /** - * OpenTuiUI — React-based full-screen `WizardUI` implementation. + * InkUI — Ink-based `WizardUI` implementation. * - * The class itself is a thin bridge between the imperative `WizardUI` + * The class is a thin bridge between the imperative `WizardUI` * surface (which the wizard runner calls into) and a React tree - * mounted via `@opentui/react`'s `createRoot`. State lives in a - * `WizardStore` (see `opentui-store.ts`) that React subscribes to via + * mounted via Ink's `render()`. State lives in a `WizardStore` + * (see `wizard-store.ts`) that React subscribes to via * `useSyncExternalStore`. Each method on this class translates a * single imperative call into one or more store mutations; React * re-renders. * - * Why React rather than imperative Renderable mutation? + * Why Ink rather than OpenTUI? * - * - Multi-select with toggle state was racy under direct - * `SelectRenderable.setOptions()` calls — keystrokes could land - * between the toggle and the redraw, leaving the visible markers - * out of sync with the internal set. - * - The Sentry-tips sidebar rotates on a timer; React's prop diff - * handles the swap with no manual `text.content =` plumbing. - * - The completion summary uses structured data (key/value rows, - * changed-files list) rather than pre-rendered markdown, which - * OpenTUI's TextRenderable can't display correctly. React's - * declarative composition is the natural way to lay it out. + * - **Runs on Node.** OpenTUI's renderer is Zig-compiled and only + * loadable from Bun's `bun:ffi`. The npm/Node distribution of + * the CLI couldn't use it, so half the user base got a + * plain-text fallback. Ink is pure JS + React, so this same + * UI runs everywhere the CLI does. + * - **No native binary cost.** The OpenTUI implementation added + * ~10.7 MB to the compiled Bun binary (the `libopentui.so` + * plus the ~12k-line generated FFI bindings). Ink + companions + * add ~1–2 MB and are pure JS, so they bundle cleanly. + * - **Inline rendering.** Ink writes incrementally to stdout, so + * log lines naturally end up in the user's scrollback. OpenTUI + * needed an alternate-screen buffer + a post-dispose stderr + * replay to leave any trace of the run behind. * - * **Bun-only.** OpenTUI's native bindings ship as Zig — they don't run - * on the npm/Node distribution. The factory in `factory.ts` only - * routes here when running inside the Bun-compiled binary. - * - * **Lazy import.** `@opentui/core`, `@opentui/react`, and `react` are - * all dynamically imported by `createOpenTuiUI()` so the npm bundle - * (which excludes them from the bundle graph) never sees the imports - * at module-load time. + * **Lazy import.** `ink`, `ink-spinner`, `ink-select-input`, and + * `react` are all dynamically imported by `createInkUI()` so the + * npm bundle (which excludes them from the bundle graph) never sees + * the imports at module-load time. This keeps the `LoggingUI` path + * cheap to instantiate when interactive UI is not needed. */ import chalk from "chalk"; import { stripAnsi } from "../../formatters/plain-detect.js"; import { buildFileTree, flattenTree } from "./file-tree.js"; -import { WizardStore } from "./opentui-store.js"; import { SENTRY_TIPS } from "./sentry-tips.js"; - -// Brand palette mirrored from `opentui-app.tsx` — kept in sync so the -// post-dispose stderr report (rendered via chalk, not OpenTUI) feels -// like a continuation of the wizard's live screen rather than a -// separate, plainer surface. -const REPORT_MUTED = "#6E6C7E"; -const REPORT_SUCCESS = "#86EFAC"; -const REPORT_ERROR = "#F87171"; -const REPORT_WARN = "#FBBF24"; - import { CANCELLED, type Cancelled, @@ -59,9 +48,15 @@ import { type WizardSummary, type WizardUI, } from "./types.js"; +import { WizardStore } from "./wizard-store.js"; -/** Spinner cadence — matches `LoggingUI`/legacy spinner cadence. */ -const SPINNER_INTERVAL_MS = process.platform.startsWith("win") ? 80 : 120; +// Brand palette mirrored from `ink-app.tsx` so the post-dispose +// success/failure echo (rendered via chalk after Ink unmounts) feels +// like a continuation of the live screen. +const REPORT_MUTED = "#6E6C7E"; +const REPORT_SUCCESS = "#86EFAC"; +const REPORT_ERROR = "#F87171"; +const REPORT_WARN = "#FBBF24"; /** Tip rotation cadence in the sidebar — slow enough to read each tip. */ const TIP_ROTATE_INTERVAL_MS = 8000; @@ -86,9 +81,8 @@ const BANNER_ROWS = [ ]; /** - * Log severities recognised by the OpenTUI UI. Kept narrowly typed so - * callers can't pass arbitrary strings into `appendLog`. Mirrors the - * keys of `ICON_BY_SEVERITY` in `opentui-app.tsx`. + * Log severities recognised by InkUI. Mirrors the keys of + * `ICON_BY_SEVERITY` in `ink-app.tsx`. */ type LogSeverity = "info" | "warn" | "error" | "success" | "message"; @@ -107,7 +101,7 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { } /** - * Embed `opentui-app.tsx` as a Bun-compile file resource. + * Embed `ink-app.tsx` as a Bun-compile file resource. * * `with { type: "file" }` tells Bun.compile to copy the raw .tsx * bytes into the binary's virtual filesystem and replace the import @@ -116,60 +110,49 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { * for the esbuild step (copies the file alongside the bundle and * leaves the import external). * - * Why this indirection? The React tree statically imports - * `react` + `@opentui/react`. When Bun.compile bundles those imports - * through its `__commonJS` + `__esm` async-init wrappers it generates - * malformed code (a TDZ `init_react` symbol embedded in expression - * scope), and the resulting binary crashes at startup with a parse - * error. Embedding the .tsx as raw bytes pushes the React resolution - * to Bun's runtime — which doesn't have the bug — at the cost of a - * small first-invocation parse overhead. + * Why this indirection? `ink-app.tsx` statically imports `ink`, + * `ink-spinner`, `ink-select-input`, and `react`. When Bun.compile + * bundles those packages through its CJS-wrapping path the output + * mangles their dev-build IIFEs (it injects `__promiseAll` runtime + * helpers in positions the wrappers don't tolerate, producing a + * `SyntaxError: Unexpected identifier '__promiseAll'` at startup + * inside e.g. `react/cjs/react-jsx-runtime.development.js` or + * `ink/build/parse-keypress.js`). Embedding the .tsx as raw bytes + * pushes resolution to Bun's runtime — which doesn't have the bug + * — at the cost of a small first-invocation parse overhead. * - * The npm/Node distribution never reaches `createOpenTuiUI()` (the - * factory routes there only on the Bun binary), so this import is - * harmless for the npm bundle. + * The npm/Node distribution never reaches `createInkUI()` (the + * factory routes there only on the Bun binary because Ink uses + * top-level await that esbuild can't emit in our CJS bundle), so + * the embedded file is unused on Node. We still produce it because + * the static import is unconditional; the bundle.ts cleanup step + * `unlink`s the unused sidecar after bundling. */ // @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun -import opentuiAppPath from "./opentui-app.tsx" with { type: "file" }; +import inkAppPath from "./ink-app.tsx" with { type: "file" }; /** - * Async factory for `OpenTuiUI`. Imports `@opentui/core`, - * `@opentui/react`, `react`, and the local `App` component lazily, - * mounts the React tree, and returns the bridge instance. Throws if - * any of the native bindings are missing (e.g. accidentally invoked - * from Node). + * Async factory for `InkUI`. Imports `ink`, `react`, and the local + * `App` component lazily, mounts the React tree, and returns the + * bridge instance. Throws if Ink can't be loaded (e.g. missing peer + * deps). */ -export async function createOpenTuiUI(): Promise { - // Serialize the imports — `@opentui/react` re-exports core - // primitives via its own bundle and the parallel-import path - // tripped a TDZ error inside their `chunk-*.js` because the - // re-export landed before core's class declarations. - const core = await import("@opentui/core"); - const reactBindings = await import("@opentui/react"); +export async function createInkUI(): Promise { + const ink = await import("ink"); const react = await import("react"); - // See the comment on the `opentuiAppPath` import above for why - // this goes through the embedded-file path rather than a plain - // `import("./opentui-app.js")`. The cast preserves typing against - // the source module so `app.App` keeps its component signature. - // // The `?bridge=1` query string is load-bearing. Without it Bun's // module loader hits a cache entry created by the static // `with { type: "file" }` import above (same absolute path) and // returns a synthetic `{ __esModule, default: undefined }` shape - // instead of evaluating the `.tsx` as a module — `app.App` + // instead of evaluating the .tsx as a module — `app.App` // becomes `undefined` and React throws "Element type is invalid". // The query string forces a distinct cache key while resolving to // the same on-disk file, so the .tsx is parsed and exports // populate normally. Confirmed on Bun 1.3.13 (dev) and inside // Bun-compiled binaries (the `/$bunfs/…` runtime path). const app = (await import( - `${opentuiAppPath}?bridge=1` - )) as typeof import("./opentui-app.js"); - - const renderer = await core.createCliRenderer({ - exitOnCtrlC: false, - screenMode: "alternate-screen", - }); + `${inkAppPath}?bridge=1` + )) as typeof import("./ink-app.js"); const store = new WizardStore({ bannerRows: BANNER_ROWS.map((content, i) => ({ @@ -178,74 +161,72 @@ export async function createOpenTuiUI(): Promise { })), }); - const root = reactBindings.createRoot(renderer); - // `react.createElement` is the typed JSX factory; we cast the App - // component reference so TypeScript accepts the `{ store }` props - // bag without dragging the React types into the bridge module. - root.render(react.createElement(app.App, { store })); - - // Cast the root to our local `RenderRoot` shape. The shape matches - // structurally (`render(node)` + `unmount()`); the cast just opts - // out of React's stricter `ReactNode` parameter to keep the - // imperative bridge free of React types. - return new OpenTuiUI(renderer, root as unknown as RenderRoot, store); + // Ink's render returns a handle with `unmount()` and + // `waitUntilExit()`. We don't await `waitUntilExit` here because + // the wizard drives lifecycle imperatively from the runner; the + // dispose path calls `unmount()` directly when the workflow + // finishes (success or failure). + // + // `exitOnCtrlC: false` lets us route Ctrl+C through the prompt + // cancellation path (`installCancelHandler`) instead of yanking + // the process down mid-spinner. + // + // `patchConsole: false` keeps `console.*` calls flowing to the + // real stdout — Sentry SDK breadcrumbs, debug logs, etc. would + // otherwise be swallowed by Ink's render loop. + const instance = ink.render(react.createElement(app.App, { store }), { + exitOnCtrlC: false, + patchConsole: false, + }); + + return new InkUI(instance, store); } -// Locally-scoped type aliases for the bridge — these all come from -// dynamic imports so we keep them as `unknown`-ish constraints rather -// than depending on the upstream packages' types directly. -type RenderRoot = { - render: (node: unknown) => void; +/** + * Subset of the Ink `Instance` type we actually use. + * + * Defined structurally rather than imported from `ink` so the + * dynamic-import boundary in `createInkUI` doesn't leak Ink types + * into the rest of the bridge module. `rerender` takes + * `react.ReactNode` upstream; we widen it to a generic function + * type and only ever call `unmount`/`waitUntilExit` from the bridge + * anyway. + */ +type InkInstance = { unmount: () => void; + waitUntilExit: () => Promise; + // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary + rerender: (node: any) => void; }; // ──────────────────────────── Implementation ────────────────────────── /** - * Bridge between the imperative `WizardUI` surface and the React + * Bridge between the imperative `WizardUI` surface and the Ink * `App` component. Mutations land in the `WizardStore`; React * re-renders. */ -export class OpenTuiUI implements WizardUI { - // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary - private readonly renderer: any; - private readonly root: RenderRoot; +export class InkUI implements WizardUI { + private readonly instance: InkInstance; private readonly store: WizardStore; - private spinnerTimer: ReturnType | undefined; private tipTimer: ReturnType | undefined; private tipIndex = 0; private activePromptCancel: (() => void) | undefined; + private cancelHandler: (() => void) | undefined; /** * Final wizard outcome captured by the bridge. * - * The OpenTUI alternate-screen buffer is wiped the moment - * `renderer.destroy()` runs, so anything we want the user to see in - * their scrollback has to be re-emitted to stderr after destroy. - * Earlier versions replayed every log/intro/outro line — that - * produced a noisy wall of `▸ sentry init`, `● This wizard uses - * AI…`, and intermediate spinner stops. We now keep just enough - * state to print a focused completion report: - * - * - `outroMessage` — the success line (set by `outro()`). - * - `failureMessage` — the error/cancel line (set by `cancel()` - * or by `log.error()` for a fatal abort). - * - The store's `summary` snapshot — already structured. - * - * Whichever pair is populated wins on dispose. If neither is set - * (e.g. early abort before any outcome was recorded) we print - * nothing, matching the previous "no transcript" behavior. + * Ink renders inline so the log lines naturally land in scrollback + * — we don't need to replay a transcript on dispose. We do echo + * a final success/failure summary line after `unmount()` so the + * user has a clear "what happened" signal at the bottom of the + * scrollback. */ private outroMessage: string | undefined; private failureMessage: string | undefined; - constructor( - // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary - renderer: any, - root: RenderRoot, - store: WizardStore - ) { - this.renderer = renderer; - this.root = root; + constructor(instance: InkInstance, store: WizardStore) { + this.instance = instance; this.store = store; this.startTipRotation(); this.installCancelHandler(); @@ -254,23 +235,17 @@ export class OpenTuiUI implements WizardUI { // ── Lifecycle ───────────────────────────────────────────────────── banner(_art: string): void { - // No-op — `App` paints the banner inside its alternate-screen - // header from the gradient rows pre-loaded into the store. The - // runner-supplied ANSI string is discarded (OpenTUI can't render - // embedded escape codes). + // No-op — the App paints the banner inside its header from the + // gradient rows pre-loaded into the store. The runner-supplied + // ANSI string is discarded. } intro(_title: string): void { - // No-op. The box's top-border title and the gradient banner - // already announce the wizard; an extra "▸ sentry init" line - // underneath felt redundant in user feedback. We keep the method - // on the interface for parity with `LoggingUI`, where the - // command-line shell makes a separate intro line useful. + // No-op. The outer box already has a title-bar feel via the + // banner; an extra "▸ sentry init" line felt redundant. } outro(message: string): void { - // Show the success line live in the log pane, and remember it for - // the post-dispose scrollback report. const clean = stripAnsi(message); this.appendLog("success", clean); this.outroMessage = clean; @@ -318,11 +293,6 @@ export class OpenTuiUI implements WizardUI { start: (message?: string) => { const clean = stripAnsi(message ?? ""); this.store.startSpinner(clean); - if (!this.spinnerTimer) { - this.spinnerTimer = setInterval(() => { - this.store.tickSpinner(); - }, SPINNER_INTERVAL_MS); - } }, message: (message?: string) => { if (message !== undefined) { @@ -330,16 +300,10 @@ export class OpenTuiUI implements WizardUI { } }, stop: (message?: string, code: SpinnerExitCode = 0) => { - if (this.spinnerTimer) { - clearInterval(this.spinnerTimer); - this.spinnerTimer = undefined; - } const finalMessage = message ? stripAnsi(message) : this.store.getSnapshot().spinner.message; this.store.stopSpinner(); - // Promote the spinner's final state into the log pane so it - // survives subsequent `start()` calls. if (finalMessage) { this.appendLog(severityForStopCode(code), finalMessage); } @@ -437,23 +401,18 @@ export class OpenTuiUI implements WizardUI { // ── Disposal ────────────────────────────────────────────────────── [Symbol.asyncDispose](): Promise { - if (this.spinnerTimer) { - clearInterval(this.spinnerTimer); - this.spinnerTimer = undefined; - } if (this.tipTimer) { clearInterval(this.tipTimer); this.tipTimer = undefined; } - try { - this.root.unmount(); - } catch { - // Ignore — disposal must never throw. + if (this.cancelHandler) { + process.removeListener("SIGINT", this.cancelHandler); + this.cancelHandler = undefined; } try { - this.renderer.destroy(); + this.instance.unmount(); } catch { - // Ignore. + // Ignore — disposal must never throw. } const report = this.buildPostDisposeReport(); if (report) { @@ -463,22 +422,19 @@ export class OpenTuiUI implements WizardUI { } /** - * Build the compact scrollback report shown after `destroy()` wipes - * the alternate screen. Three shapes: + * Build a compact final summary echoed to stderr after Ink + * unmounts. Ink's inline rendering means the run's log lines are + * already in the user's scrollback; this report just emphasises + * the outcome so it's the last thing on screen. * + * Three shapes: * - Success: outro line + summary fields + changed files. * - Failure: cancel/error line on its own. - * - Empty: no useful state captured (early abort, etc.) — return - * `undefined` and the caller skips the stderr write. - * - * Failure wins over success if both are set (e.g. error mid-run - * after a partial summary was emitted). + * - Empty: no useful state captured (early abort, etc.) — + * return `undefined` and the caller skips the + * stderr write. * - * The report is colored via chalk (not OpenTUI) — by the time it - * runs, `renderer.destroy()` has already restored the main screen - * and chalk's TTY detection picks up where it left off. Keeping - * the palette aligned with the live UI's brand colors makes the - * scrollback handoff feel intentional. + * Failure wins over success if both are set. */ private buildPostDisposeReport(): string | undefined { if (this.failureMessage) { @@ -532,35 +488,39 @@ export class OpenTuiUI implements WizardUI { } /** - * Wire the global Ctrl+C / Escape handler. Cooperative cancellation - * — resolve the active prompt with `CANCELLED` rather than yanking - * the process down, so `wizard-runner.ts` can drive its normal - * cleanup path (telemetry, exit code, etc.). + * Wire the global Ctrl+C / Escape handler. Cooperative + * cancellation — resolve the active prompt with `CANCELLED` + * rather than yanking the process down, so `wizard-runner.ts` + * can drive its normal cleanup path (telemetry, exit code, etc.). + * + * Ink's `useInput` only fires inside a focused component; we want + * cancellation to work even when no prompt is mounted (e.g. + * during a spinner). Hook into the process-level SIGINT instead + * — `exitOnCtrlC: false` on the render call ensures Ink doesn't + * intercept first. */ private installCancelHandler(): void { - this.renderer.keyInput.on( - "keypress", - (event: { name: string; ctrl?: boolean }) => { - const isCancel = - (event.ctrl && event.name === "c") || event.name === "escape"; - if (!isCancel) { - return; - } - const cancelFn = this.activePromptCancel; - if (cancelFn) { - cancelFn(); - } + const handler = () => { + const cancelFn = this.activePromptCancel; + if (cancelFn) { + cancelFn(); + return; } - ); + // No active prompt — surface a clean cancel message so the + // wizard runner's catch-WizardCancelledError path triggers. + // We don't `process.exit` here; the caller decides. + this.failureMessage = "Setup cancelled."; + this.instance.unmount(); + }; + this.cancelHandler = handler; + process.on("SIGINT", handler); } } /** * Colored glyph for a changed-files row in the post-dispose report. * The plain ASCII variant lives in `logging-ui.ts` for the - * non-interactive CI path. We keep both copies (vs. extracting a - * shared module) because each impl wants different rendering — chalk - * here, raw text there — and the helpers are tiny. + * non-interactive CI path. */ function changedFileGlyphColored(action: string): string { if (action === "create") { diff --git a/src/lib/init/ui/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx deleted file mode 100644 index 8cddb52cf..000000000 --- a/src/lib/init/ui/opentui-app.tsx +++ /dev/null @@ -1,843 +0,0 @@ -/** - * OpenTuiUI React App - * - * Renders the full-screen wizard layout. The component subscribes to a - * `WizardStore` (see `opentui-store.ts`) via `useSyncExternalStore` so - * imperative `WizardUI` method calls (`log.info`, `spinner.start`, - * etc.) trigger React re-renders without React state being the source - * of truth. - * - * Layout (left-aligned columns from outer chrome inwards): - * - * ┌─ Sentry init ──────────────────────────────────────────────────┐ - * │ ╔═══════════════════════════╗ ╔══════════════════════════╗ │ - * │ ║ banner ║ ║ Did you know? ║ │ - * │ ║ ────────── ║ ║ ────────────── ║ │ - * │ ║ ● log line ║ ║ ║ │ - * │ ║ ▲ log line ║ ║ ║ │ - * │ ║ ◐ Reading foo.ts (3) ║ ║ ║ │ - * │ ║ ◒ spinner... ║ ║ Tip 3 of 12 ║ │ - * │ ║ ║ ╚══════════════════════════╝ │ - * │ ╚═══════════════════════════╝ │ - * └────────────────────────────────────────────────────────────────┘ - * - * The file-read status line is a single transient row above the - * spinner — replaces the previous bordered "Files analyzed" panel - * that pushed the tip card off-screen on shorter terminals. - * - * Why an external store rather than React state owned by the App? - * The `WizardUI` interface is imperative (the wizard runner calls - * `ui.log.info(...)` from a generator). Threading those calls through - * React's state setters from outside React would require keeping a - * mutable reference to a setter that gets bound on first render — - * fragile, especially with concurrent mode. An external store keeps - * the imperative side decoupled from React's lifecycle. - */ - -import { basename } from "node:path"; -import { useKeyboard, useTerminalDimensions } from "@opentui/react"; -import { useState, useSyncExternalStore } from "react"; -import { - buildFileTree, - buildReadTree, - type FileTreeRow, - flattenTree, -} from "./file-tree.js"; -import type { - ActivePrompt, - FileReadEntry, - LogEntry, - LogSeverity, - SpinnerState, - StepEntry, - WizardStore, -} from "./opentui-store.js"; -import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; -import type { WizardSummary } from "./types.js"; - -// ──────────────────────────── Visual constants ──────────────────────── - -const ACCENT = "#A77DC3"; -const MUTED = "#6E6C7E"; -const FOREGROUND = "#E8E6F0"; - -const COLOR_INFO = "#7DD3FC"; -const COLOR_WARN = "#FBBF24"; -const COLOR_ERROR = "#F87171"; -const COLOR_SUCCESS = "#86EFAC"; - -const SPINNER_FRAMES = process.platform.startsWith("win") - ? ["●", "o", "O", "0"] - : ["◒", "◐", "◓", "◑"]; - -const ICON_BY_SEVERITY: Record = - { - info: { glyph: "●", color: COLOR_INFO }, - warn: { glyph: "▲", color: COLOR_WARN }, - error: { glyph: "✖", color: COLOR_ERROR }, - success: { glyph: "✔", color: COLOR_SUCCESS }, - message: { glyph: " ", color: FOREGROUND }, - }; - -// ────────────────────────────── App entry ───────────────────────────── - -export type AppProps = { - store: WizardStore; -}; - -/** - * Width of the sidebar's outer box, including its border + padding. - * Used both as the renderable's `width` prop and as part of the - * minimum-terminal-width threshold below which we hide the sidebar. - */ -const SIDEBAR_WIDTH = 36; - -/** - * Minimum terminal columns required to show the sidebar alongside the - * main column. Below this we drop the sidebar entirely so the banner, - * log lines, and prompts get the full row width. - * - * Reasoning: the banner is ~55 chars wide, the outer wizard chrome - * eats 2 cols of border + 2 cols of padding (4 total), the inner gap - * between columns is 2, plus the sidebar's own 36 cols → 55 + 4 + 2 + - * 36 = 97. We round up slightly to leave room for prompts and longer - * log lines without wrapping ugly. - */ -const SIDEBAR_BREAKPOINT = 100; - -/** - * Fixed height for the tip card. Pinned (rather than `flexGrow`) so - * the panels below it (progress checklist, files-read tree) can never - * push the tip out of view as more content streams in. Sized to fit: - * - * 1 row – top border - * 1 row – top padding - * 1 row – tip title - * 1 row – gap - * 4 rows – tip body (wrapping room) - * 1 row – bottom padding (filler before counter) - * 1 row – "Tip n of N" counter - * 1 row – bottom padding - * 1 row – bottom border - * - * Bumping this knob is cheap; no other layout depends on it directly. - */ -const TIP_PANEL_HEIGHT = 12; - -/** - * Root component. Subscribes to the store once at the top, then drills - * the snapshot fields into individual presentational components. - * - * The sidebar auto-hides on narrow terminals (see `SIDEBAR_BREAKPOINT`) - * — `useTerminalDimensions` re-renders on resize, so dragging a - * window between widths flips the layout live. - */ -export function App({ store }: AppProps): React.ReactNode { - const snapshot = useSyncExternalStore( - store.subscribe, - store.getSnapshot, - store.getSnapshot - ); - const { width } = useTerminalDimensions(); - const showSidebar = width >= SIDEBAR_BREAKPOINT; - - return ( - - - - {showSidebar ? ( - - ) : null} - - - ); -} - -// ──────────────────────────── Main column ───────────────────────────── - -type MainColumnProps = { - bannerRows: { content: string; color: string }[]; - filesRead: FileReadEntry[]; - logs: LogEntry[]; - spinner: SpinnerState; - prompt: ActivePrompt | null; - summary: WizardSummary | null; - /** - * Whether to render the inline file-read status row above the - * spinner. We only show this when the sidebar is hidden (narrow - * terminals); otherwise the sidebar's `FilesPanel` gives a richer - * tree view and the inline row would be a noisy duplicate. - */ - showFileReadInline: boolean; -}; - -function MainColumn({ - bannerRows, - filesRead, - logs, - spinner, - prompt, - summary, - showFileReadInline, -}: MainColumnProps): React.ReactNode { - // Hide the file-read status once the wizard finishes — the summary - // panel is the canonical "what happened" surface at that point, and - // a stale "47 files analyzed" line below it would just be noise. - const showFileStatus = showFileReadInline && !summary && filesRead.length > 0; - return ( - -
- - - {logs.map((log) => ( - - ))} - - {showFileStatus ? : null} - {spinner.active ? : null} - {summary ? : null} - {prompt ? : null} - - ); -} - -function Header({ - bannerRows, -}: { - bannerRows: { content: string; color: string }[]; -}): React.ReactNode { - // The box already advertises "sentry init" in its top border title, - // and the banner itself reads "SENTRY", so we don't repeat the - // command name underneath the banner. Earlier versions had an - // intro line here ("▸ sentry init") which felt redundant. - return ( - - {bannerRows.map((row, i) => ( - // ASCII banner rows are positional, stable, and never re-ordered — - // the index key is correct here. - // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows - - {row.content} - - ))} - - ); -} - -function Divider(): React.ReactNode { - return ( - - ); -} - -function LogLine({ entry }: { entry: LogEntry }): React.ReactNode { - const { glyph, color } = ICON_BY_SEVERITY[entry.severity]; - return ( - - - {glyph} - - - {entry.text} - - - ); -} - -function SpinnerRow({ state }: { state: SpinnerState }): React.ReactNode { - const frame = - SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length] ?? - SPINNER_FRAMES[0] ?? - "•"; - return ( - - - {frame} - - - {state.message} - - - ); -} - -/** - * Single-line file-read status, shown above the spinner. Replaces the - * old bordered "Files analyzed" sidebar panel which had a fixed - * `flexShrink={0}` height of ~13 rows and pushed the tip card off- - * screen on shorter terminals. - * - * Rendering rules: - * - If any file is currently `reading`: show a yellow ● glyph plus - * up to two recent basenames and the running counter, e.g. - * `● Reading package.json, sentry.config.ts (3/12 analyzed)`. - * - Otherwise: collapse to a green ✔ recap, e.g. - * `✔ Analyzed 12 files`. - * - * The component never wraps to a second line — long basenames are - * truncated by the terminal, which is fine: the goal is a glance-able - * indicator, not a log. - */ -function FileReadStatus({ - filesRead, -}: { - filesRead: FileReadEntry[]; -}): React.ReactNode { - const reading = filesRead.filter((entry) => entry.status === "reading"); - const analyzed = filesRead.length - reading.length; - - if (reading.length > 0) { - // Show the most-recent 2 basenames being read; anything more turns - // into a `+ N more` hint so the line stays single-row. - const recent = reading.slice(-2).map((entry) => basename(entry.path)); - const overflow = reading.length - recent.length; - const namesPart = - overflow > 0 - ? `${recent.join(", ")} + ${overflow} more` - : recent.join(", "); - return ( - - - ● - - - Reading {namesPart} - - - {analyzed}/{filesRead.length} analyzed - - - ); - } - - return ( - - - ✔ - - - Analyzed {analyzed} {analyzed === 1 ? "file" : "files"} - - - ); -} - -// ────────────────────────────── Summary ─────────────────────────────── - -/** - * Compact summary panel rendered after the workflow finishes. Replaces - * the old approach of pushing pre-rendered markdown through - * `ui.log.message`, which OpenTuiUI couldn't display correctly because - * it strips ANSI and shows tag literals like `~`. - * - * Each field is a single row: small dim label cell followed by the - * value. Changed-files get a one-line-per-file rendering with an - * action glyph (+ ~ −). - */ -function SummaryPanel({ - summary, -}: { - summary: WizardSummary; -}): React.ReactNode { - return ( - - {summary.fields.length > 0 ? ( - - {summary.fields.map((field) => ( - - - {field.label} - - - {field.value} - - - ))} - - ) : null} - {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( - - ) : null} - - ); -} - -/** - * Render the changed-files list as a nested directory tree. Files - * sharing a parent directory collapse into a single group, and the - * box-drawing prefix (`├─` / `└─` / `│ `) tracks ancestor pipes the - * way `tree(1)` does. The tree shape is computed by `buildFileTree` - * — this component is purely presentational. - */ -function ChangedFilesTree({ - files, -}: { - files: { action: string; path: string }[]; -}): React.ReactNode { - const tree = buildFileTree(files); - const rows = flattenTree(tree); - return ( - - Changed files - {rows.map((row, i) => ( - // Tree rows are positionally stable for a given summary — - // the tree is rebuilt fresh each render from immutable - // `files`, so the index makes a fine key. - // biome-ignore lint/suspicious/noArrayIndexKey: positional tree rows - - ))} - - ); -} - -function FileTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { - if (row.kind === "directory") { - return ( - - {`${row.prefix}${row.branch} `} - {row.label} - - ); - } - const { glyph, color } = changedFileStyle(row.action ?? "modify"); - return ( - - {`${row.prefix}${row.branch} `} - {`${glyph} `} - {row.label} - - ); -} - -/** - * Map a change action to its glyph + color. Stays here next to the row - * component because both pieces of styling are coupled to the same - * action enum (create / delete / modify-or-other). - */ -function changedFileStyle(action: string): { glyph: string; color: string } { - if (action === "create") { - return { glyph: "+", color: COLOR_SUCCESS }; - } - if (action === "delete") { - return { glyph: "−", color: COLOR_ERROR }; - } - return { glyph: "~", color: COLOR_WARN }; -} - -// ─────────────────────────────── Prompts ────────────────────────────── - -function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { - if (prompt.kind === "select") { - return ; - } - return ; -} - -function SelectPrompt({ - prompt, -}: { - prompt: Extract; -}): React.ReactNode { - // OpenTUI's SelectRenderable allocates 2 rows per option when - // `showDescription` is on (1 for the label + 1 for the hint), - // 1 row otherwise. Allocating the wrong height clips visible - // rows behind the scroll. We size based on the actual line cost - // and cap at the screen-friendly maxima the wizard expects - // (8 fully-shown items for select, 10 for multiselect). - const hasDescriptions = prompt.options.some((option) => option.hint); - const linesPerItem = hasDescriptions ? 2 : 1; - const maxVisibleItems = 8; - const visibleItems = Math.min(prompt.options.length, maxVisibleItems); - return ( - - {prompt.message} - setHighlighted(index)} - options={decoratedOptions} - selectedBackgroundColor={ACCENT} - selectedTextColor="#FFFFFF" - showDescription={hasDescriptions} - showScrollIndicator={prompt.options.length > maxVisibleItems} - textColor={FOREGROUND} - /> - - ); -} - -// ────────────────────────────── Sidebar ─────────────────────────────── - -/** - * The sidebar stacks three panels top-to-bottom: - * - * 1. {@link TipPanel} — fixed height (`TIP_PANEL_HEIGHT`). Pinned so - * it can never be squashed by the panels below. - * 2. {@link ProgressPanel} — auto height (one row per visible step). - * Bounded by `CHECKLIST_VISIBLE_STEPS.length` (~9 rows). - * 3. {@link FilesPanel} — `flexGrow=1`, scrollable. Consumes - * whatever vertical space is left over. - * - * On narrow terminals (`width < SIDEBAR_BREAKPOINT`) the whole - * sidebar is hidden by the parent App; the inline `FileReadStatus` - * line in `MainColumn` takes over the file-read indicator role. - */ -function Sidebar({ - tipIndex, - steps, - filesRead, -}: { - tipIndex: number; - steps: StepEntry[]; - filesRead: FileReadEntry[]; -}): React.ReactNode { - return ( - - - - - - ); -} - -function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { - const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; - const total = SENTRY_TIPS.length; - const oneIndexed = (tipIndex % total) + 1; - return ( - - {tip.title} - {tip.body} - - - Tip {oneIndexed} of {total} - - - ); -} - -/** - * Static checklist of workflow steps. Each row reflects a - * `StepEntry.status`: - * - * - `pending` — muted ◯ - * - `in_progress` — accent ▶ - * - `completed` — success ✓ - * - `skipped` — muted-dim ◌ (lighter than pending so the eye - * can tell "we walked past this" from "we haven't reached this - * yet") - * - `failed` — error ✖ - * - * The label cell is sized to fit the 36-col sidebar after the - * 2-col border + 2-col padding + 2-col glyph cell. - */ -function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { - const completedCount = steps.filter( - (entry) => entry.status === "completed" - ).length; - const totalCount = steps.length; - return ( - - {steps.map((entry) => ( - - ))} - - ); -} - -function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { - const { glyph, glyphColor, labelColor } = progressStyle(entry.status); - return ( - - - {glyph} - - - {entry.label} - - - ); -} - -function progressStyle(status: StepEntry["status"]): { - glyph: string; - glyphColor: string; - labelColor: string; -} { - if (status === "in_progress") { - return { glyph: "▶", glyphColor: ACCENT, labelColor: FOREGROUND }; - } - if (status === "completed") { - return { glyph: "✓", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; - } - if (status === "failed") { - return { glyph: "✖", glyphColor: COLOR_ERROR, labelColor: COLOR_ERROR }; - } - if (status === "skipped") { - return { glyph: "◌", glyphColor: MUTED, labelColor: MUTED }; - } - // pending - return { glyph: "◯", glyphColor: MUTED, labelColor: MUTED }; -} - -/** - * Scrollable directory tree of every file the wizard has read. Uses - * `` (OpenTUI's `ScrollBoxRenderable`) with sticky-bottom - * tracking — newly-read files always come into view, like a - * `tail -f`. - * - * Visual rules: - * - Directories: muted gray box-drawing branches + name with `/`. - * - Active reads (`status === "reading"`): accent purple `◐` glyph, - * foreground filename. The eye picks these out instantly. - * - Analyzed (`status === "analyzed"`): muted-green `✓` glyph, - * dimmed filename. Done work recedes; in-flight work pops. - * - * Hidden when no files have been recorded yet — the empty box would - * just be visual noise during the auth/discover phase. - */ -function FilesPanel({ - filesRead, -}: { - filesRead: FileReadEntry[]; -}): React.ReactNode { - if (filesRead.length === 0) { - return null; - } - const tree = buildReadTree(filesRead); - const rows = flattenTree(tree); - const analyzedCount = filesRead.filter( - (entry) => entry.status === "analyzed" - ).length; - return ( - - - {rows.map((row, i) => ( - // Tree rows are positionally stable for a given filesRead - // snapshot — `buildReadTree` walks `filesRead` in insertion - // order and never reorders, so the index makes a fine key. - // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows - - ))} - - - ); -} - -/** - * One row of the files-read tree. Mirrors {@link FileTreeLine} but - * styled for the read-progress flavour (status icons + dim-on-done) - * rather than the changed-files flavour (action glyphs). - */ -function ReadTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { - if (row.kind === "directory") { - return ( - - {`${row.prefix}${row.branch} `} - {row.label} - - ); - } - const { glyph, glyphColor, labelColor } = readStatusStyle(row.status); - return ( - - {`${row.prefix}${row.branch} `} - {`${glyph} `} - {row.label} - - ); -} - -function readStatusStyle(status: FileTreeRow["status"]): { - glyph: string; - glyphColor: string; - labelColor: string; -} { - if (status === "reading") { - return { glyph: "◐", glyphColor: ACCENT, labelColor: FOREGROUND }; - } - // "analyzed" or undefined (defensive — should never appear for - // file rows but treat as analyzed) - return { glyph: "✓", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; -} diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 48554469f..3a5a756e0 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -4,8 +4,11 @@ * Defines the I/O surface used by the init wizard. Concrete implementations * provide the actual rendering: * - * - `OpenTuiUI` — alternate-buffer full-screen UI built on `@opentui/core`. - * Default for interactive runs on the Bun-compiled binary. + * - `InkUI` — Ink-based React UI. Default for interactive runs on + * the Bun-compiled binary. Ink is pure JS but uses + * top-level await internally, which esbuild can't emit + * in our CJS npm bundle — so the npm/Node distribution + * falls back to `LoggingUI` instead. * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, non-TTY * environments, the npm/Node distribution, and the * `--no-tui` escape hatch. Prompts throw — diff --git a/src/lib/init/ui/opentui-store.ts b/src/lib/init/ui/wizard-store.ts similarity index 97% rename from src/lib/init/ui/opentui-store.ts rename to src/lib/init/ui/wizard-store.ts index aa36542fa..d58282790 100644 --- a/src/lib/init/ui/opentui-store.ts +++ b/src/lib/init/ui/wizard-store.ts @@ -1,8 +1,8 @@ /** - * OpenTuiUI State Store + * Wizard UI State Store * * Tiny external store that bridges the imperative `WizardUI` methods - * to React's render loop. The `OpenTuiUI` class mutates this store + * to React's render loop. The `InkUI` class mutates this store * (intro text, log entries, spinner state, active prompt) and the * React `App` subscribes via `useSyncExternalStore`. * @@ -14,6 +14,9 @@ * The store is intentionally minimal: snapshots are plain immutable * objects so React's default `Object.is` reference check is enough * to detect changes. + * + * Originally written for OpenTUI; the data shape ported one-to-one to + * Ink because nothing here is specific to OpenTUI's component model. */ import { @@ -43,7 +46,7 @@ export type SpinnerState = { * One entry tracking a file the wizard has read from disk during the * session. Status transitions `reading` → `analyzed` once the tool * returns. Surfaced by the inline file-read status line in `OpenTuiUI` - * (see `FileReadStatus` in `opentui-app.tsx`). + * (see `FileReadStatus` in `ink-app.tsx`). */ export type FileReadEntry = { path: string; @@ -88,7 +91,7 @@ export type PromptOption = { * prompt is active. Each variant carries the data the matching React * component needs plus a `resolve` callback that the component invokes * with the user's choice (or with `null` to indicate cancellation — - * the bridge in `opentui-ui.ts` translates `null` to the shared + * the bridge in `ink-ui.ts` translates `null` to the shared * `CANCELLED` sentinel before handing the value back to the wizard). */ export type ActivePrompt = diff --git a/test/lib/init/ui/factory.test.ts b/test/lib/init/ui/factory.test.ts index 63df35ae6..15827090a 100644 --- a/test/lib/init/ui/factory.test.ts +++ b/test/lib/init/ui/factory.test.ts @@ -2,18 +2,20 @@ * Tests for getUIAsync() — verifies the runtime-detection rules pick * the right WizardUI implementation. * - * The factory's selection logic depends on four signals: + * The factory's selection logic depends on five signals: * - `SENTRY_INIT_TUI` env var * - `--yes` flag (passed in via opts) * - `--no-tui` (mapped to `forceLegacy`) * - stdin/stdout TTY state - * - whether the runtime is the Bun-compiled binary + * - whether the runtime is the Bun-compiled binary (Ink is + * gated to Bun because its top-level-await usage doesn't + * bundle into our CJS npm distribution). * * We patch the env and `process.stdin.isTTY` / `process.stdout.isTTY` * around each test so the assertions are deterministic. The * Bun-runtime branch is exercised by leaving `isBunRuntime()` to its * real return value — the test runner is invoked via `bun test` so - * the Bun global is present and `getUIAsync` can attempt the OpenTUI + * the Bun global is present and `getUIAsync` can attempt the Ink * path. To keep tests fast and TTY-independent we use the * `forceLegacy` / non-TTY / `--yes` paths to assert `LoggingUI` is * returned without ever spinning up a real renderer. diff --git a/test/lib/init/ui/opentui-store.test.ts b/test/lib/init/ui/wizard-store.test.ts similarity index 95% rename from test/lib/init/ui/opentui-store.test.ts rename to test/lib/init/ui/wizard-store.test.ts index d59af5c47..14c63bbd8 100644 --- a/test/lib/init/ui/opentui-store.test.ts +++ b/test/lib/init/ui/wizard-store.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the OpenTUI wizard store's step-progress state. + * Tests for the wizard store's step-progress state. * * Covers: * - canonical pre-population from CHECKLIST_VISIBLE_STEPS @@ -8,8 +8,8 @@ * - idempotent re-entry (a step suspending multiple times) * - protection against `skipped` clobbering completed entries * - * The OpenTUI app itself is not tested here — see the React tree - * verification via direct `createOpenTuiUI()` invocation in + * The Ink app itself is not tested here — see the React tree + * verification via direct `createInkUI()` invocation in * dev/binary builds. This test file focuses on the pure data layer. */ @@ -18,7 +18,7 @@ import { CANONICAL_STEP_ORDER, CHECKLIST_VISIBLE_STEPS, } from "../../../../src/lib/init/clack-utils.js"; -import { WizardStore } from "../../../../src/lib/init/ui/opentui-store.js"; +import { WizardStore } from "../../../../src/lib/init/ui/wizard-store.js"; describe("WizardStore step progress", () => { test("pre-populates the checklist from CHECKLIST_VISIBLE_STEPS", () => { diff --git a/tsconfig.json b/tsconfig.json index 60d6c9569..9e4f7c8e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,6 @@ "target": "ESNext", "module": "ESNext", "jsx": "react-jsx", - "jsxImportSource": "@opentui/react", "moduleDetection": "force", "allowJs": true, "moduleResolution": "bundler", From 59900dbd93e2c6985724095e2db9fc9e075dc175 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:53:42 +0000 Subject: [PATCH 2/9] fix(init): make Ink select prompt actually respond to arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `ink-select-input` with a hand-rolled select prompt built on Ink's `useInput` hook directly. Same pattern as our existing `MultiSelectPrompt` — same cursor glyph, same accent color, same hint placement, same keyboard handling. Why? `ink-select-input`'s items array is recreated on every parent render, which races with its internal `useEffect` that resets `selectedIndex` on items-change. Under our store-driven re-render cadence (tip rotation every 8s, log lines, file-read updates) the cursor never settled and arrow keys felt unresponsive — the user reported the experimental-confirm prompt couldn't be navigated or selected. Doing the same `useInput`-based render that `MultiSelectPrompt` already uses gives us: - Stable state across re-renders (cursor lives in our own `useState`, no externally-driven reset). - Consistent visual styling between single- and multi-select. - Escape-to-cancel handling. The bridge translates `resolve(null)` to the shared `CANCELLED` sentinel, so the wizard runner's cancellation path triggers cleanly. Also drops `ink-select-input` and `ink-text-input` from devDeps (both unused now) and updates the build/bundle externals lists. Verification: - bun run typecheck (clean) - bun x ultracite check (1 pre-existing warning, no new ones) - bun test --isolate test/lib/init/ (227 pass) - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, no size regression) - InkUI smoke test renders cleanly through the dispose path. --- bun.lock | 14 ---- package.json | 2 - script/build.ts | 2 - script/bundle.ts | 2 - src/lib/init/ui/ink-app.tsx | 142 ++++++++++++++++++++++++++---------- src/lib/init/ui/ink-ui.ts | 6 +- 6 files changed, 107 insertions(+), 61 deletions(-) diff --git a/bun.lock b/bun.lock index 59741d96f..cbcbee40b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,9 +29,7 @@ "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", "ink": "^7.0.1", - "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", @@ -403,8 +401,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - "finalhandler": ["finalhandler@1.3.2", "", { "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" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -465,12 +461,8 @@ "ink": ["ink@7.0.1", "", { "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" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w=="], - "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], - "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], - "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=="], - "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -485,8 +477,6 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], @@ -689,8 +679,6 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -789,8 +777,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], diff --git a/package.json b/package.json index b450cb762..8d676ae1a 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,7 @@ "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", "ink": "^7.0.1", - "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", diff --git a/script/build.ts b/script/build.ts index 44689f217..73a8e0165 100644 --- a/script/build.ts +++ b/script/build.ts @@ -140,8 +140,6 @@ async function bundleJs(): Promise { "bun:*", "ink", "ink-spinner", - "ink-select-input", - "ink-text-input", "react", "react/*", "react-reconciler", diff --git a/script/bundle.ts b/script/bundle.ts index aecf391b6..0e60bc58a 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -232,8 +232,6 @@ const result = await build({ "node:*", "ink", "ink-spinner", - "ink-select-input", - "ink-text-input", "react", "react/*", "react-reconciler", diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 2046c779e..f0f41f6b1 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -47,7 +47,6 @@ */ import { Box, Text, useInput, useStdout } from "ink"; -import SelectInput from "ink-select-input"; import Spinner from "ink-spinner"; import { useEffect, useState, useSyncExternalStore } from "react"; import { @@ -474,30 +473,83 @@ function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { return ; } +/** + * Single-select prompt rendered via Ink's `useInput` directly + * (rather than through `ink-select-input`). + * + * Why hand-rolled? + * - `ink-select-input`'s items array is recreated on every parent + * render, which races with its internal `useEffect` that resets + * `selectedIndex` on items-change. Under our store-driven + * re-render cadence (tip rotation, log lines, file-read + * updates) the cursor would never settle and arrow keys felt + * unresponsive. + * - Sharing the rendering pattern with {@link MultiSelectPrompt} + * keeps the visual styling consistent: same cursor glyph, + * same accent color, same hint placement. + * + * Keyboard: + * - up/down → move the cursor (wraps top↔bottom) + * - enter → commit the highlighted option + */ function SelectPrompt({ prompt, }: { prompt: Extract; }): React.ReactNode { - // Build the items array once per options change. `ink-select-input` - // keys items by `key` (or falls back to `value`) so we explicitly - // set `key` to dodge a duplicate-key warning when two options share - // a label but differ in `value`. - const items = prompt.options.map((option) => ({ - key: option.value, - label: option.hint ? `${option.label} ${option.hint}` : option.label, - value: option.value, - })); + const totalCount = prompt.options.length; + const [highlighted, setHighlighted] = useState(() => + Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)) + ); + + useInput((_input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + if (key.downArrow) { + setHighlighted((idx) => (idx + 1) % totalCount); + return; + } + if (key.escape) { + // Cooperative cancel — resolves the prompt with `null`, which + // the bridge translates to `CANCELLED`. + prompt.resolve(null); + return; + } + if (key.return) { + const current = prompt.options[highlighted]; + if (current) { + prompt.resolve(current.value); + } + } + }); + return ( {prompt.message} - { - prompt.resolve(String(item.value)); - }} - /> + + {prompt.options.map((option, idx) => { + const isCursor = idx === highlighted; + let cursor = " "; + let labelColor = MUTED; + if (isCursor) { + cursor = "›"; + labelColor = "white"; + } + return ( + + + {cursor} + + {option.label} + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + })} + ); } @@ -524,6 +576,33 @@ function MultiSelectPrompt({ const [highlighted, setHighlighted] = useState(0); const totalCount = prompt.options.length; + const toggleAt = (idx: number) => { + const current = prompt.options[idx]; + if (!current) { + return; + } + setSelected((prev) => { + const next = new Set(prev); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + return next; + }); + }; + + const commit = () => { + if (prompt.required && selected.size === 0) { + return; + } + // Preserve source option order in the returned array. + const ordered = prompt.options + .map((option) => option.value) + .filter((value) => selected.has(value)); + prompt.resolve(ordered); + }; + useInput((input, key) => { if (key.upArrow) { setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); @@ -533,31 +612,18 @@ function MultiSelectPrompt({ setHighlighted((idx) => (idx + 1) % totalCount); return; } + if (key.escape) { + // Cooperative cancel — resolves with `null`, which the bridge + // translates to `CANCELLED`. + prompt.resolve(null); + return; + } if (input === " ") { - const current = prompt.options[highlighted]; - if (!current) { - return; - } - setSelected((prev) => { - const next = new Set(prev); - if (next.has(current.value)) { - next.delete(current.value); - } else { - next.add(current.value); - } - return next; - }); + toggleAt(highlighted); return; } if (key.return) { - if (prompt.required && selected.size === 0) { - return; - } - // Preserve source option order in the returned array. - const ordered = prompt.options - .map((option) => option.value) - .filter((value) => selected.has(value)); - prompt.resolve(ordered); + commit(); } }); diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 2c2b52c1a..299c35257 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -111,9 +111,9 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { * leaves the import external). * * Why this indirection? `ink-app.tsx` statically imports `ink`, - * `ink-spinner`, `ink-select-input`, and `react`. When Bun.compile - * bundles those packages through its CJS-wrapping path the output - * mangles their dev-build IIFEs (it injects `__promiseAll` runtime + * `ink-spinner`, and `react`. When Bun.compile bundles those + * packages through its CJS-wrapping path the output mangles their + * dev-build IIFEs (it injects `__promiseAll` runtime * helpers in positions the wrappers don't tolerate, producing a * `SyntaxError: Unexpected identifier '__promiseAll'` at startup * inside e.g. `react/cjs/react-jsx-runtime.development.js` or From b4a591e24f5c6659afabec81c04b674a2a0b2510 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:09:49 +0000 Subject: [PATCH 3/9] fix(init): make Ink useInput actually deliver keystrokes in Bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: a known Bun + Ink interaction bug (oven-sh/bun#6862, vadimdemedes/ink#636, both still open). Ink's `useInput` hook listens for `readable` events on its stdin (default `process.stdin`) and pulls bytes via `stdin.read()`. Bun's compiled binaries have a long-standing issue where the inherited fd 0 accepts `setRawMode(true)` but never delivers `readable` events for terminal input. So: - the wizard rendered fine (Ink's stdout writes are unaffected), - but arrow keys, Enter, and Ctrl+C all did nothing — `useInput` listeners never fired, - and "can't exit the program" because raw mode suppresses SIGINT delivery for Ctrl+C, and our SIGINT fallback handler never ran either. Fix: open a fresh `/dev/tty` `tty.ReadStream` ourselves and pass it to Ink as the `stdin` option. Fresh fds opened from inside the process don't trigger the inheritance bug, so their `readable` events fire correctly. Ink's `setRawMode(true)` on the fresh stream toggles termios on the underlying TTY device — the same device fd 0 points at — so the user's terminal still goes raw, just via a different fd. We close the stream on dispose to release the libuv handle. Bonus fixes wrapped in: 1. **Ctrl+C handling in raw mode.** Each prompt's `useInput` now treats `key.ctrl && input === "c"` as a cancel (same path as Esc). A top-level `useInput` in the App component handles Ctrl+C during spinners (no prompt mounted) by calling `process.exit(130)` so users can always abort. 2. **Removed dead `forwardFreshTtyToStdin()` call.** The macOS-only workaround in `wizard-runner.ts` was clack-era dead code: `LoggingUI` doesn't read stdin (its prompts throw), and `InkUI` now opens its own /dev/tty. The function is preserved in `stdin-reopen.ts` for future callers but no longer wired in. This also removes a class of conflicts where the workaround's no-op `_read` and data-event forwarding actively broke Ink's stdin reading on macOS. 3. **Stdin teardown.** `InkUI.[Symbol.asyncDispose]` now calls `setRawMode(false)` and `destroy()` on the fresh stream so the user's shell isn't left in raw mode if the wizard crashes mid-prompt. Verification: - bun run typecheck (clean) - bun x ultracite check (1 pre-existing warning, no new ones) - bun test --isolate test/lib/init/ (227 pass) - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, no size regression) - Binary smoke (init --help) renders cleanly. - Embedded ink-app.tsx + new openFreshTtyForInk helper visible in compiled binary's strings dump. Caveats this fix carries forward: - Still requires `react-devtools-core` as a devDep so Bun.compile can resolve Ink's static reference (gated behind `process.env.DEV === "true"` at runtime, dead code in prod). - macOS-only force-exit timer in `init.ts` still fires after runWizard returns to drain the libuv handle for our fresh /dev/tty stream (same root cause as before, just different fd source). Comment updated to reflect the new owner. --- src/commands/init.ts | 26 +++---- src/lib/init/ui/ink-app.tsx | 33 +++++++-- src/lib/init/ui/ink-ui.ts | 134 +++++++++++++++++++++++++++------- src/lib/init/wizard-runner.ts | 36 ++++----- 4 files changed, 161 insertions(+), 68 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 3221c60a9..6c2c91111 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -309,21 +309,21 @@ export const initCommand = buildCommand< } finally { // 7. macOS-only force-exit safety net. // - // On Darwin, `runWizard` installs the `/dev/tty` forwarding - // workaround from stdin-reopen.ts to get keystrokes through to - // clack. That workaround opens a second `tty.ReadStream` which - // leaks a libuv handle on Bun 1.3.11 — no userland cleanup - // releases it (upstream oven-sh/bun#29126). After `runWizard` - // returns (or throws), the event loop stays ref'd and the process - // hangs until the user presses a key. + // On Darwin, `InkUI` opens a fresh `/dev/tty` `tty.ReadStream` + // (so Ink's `useInput` actually receives keystrokes — Bun's + // `process.stdin` doesn't deliver `readable` events properly, + // see oven-sh/bun#6862 / vadimdemedes/ink#636). The fresh + // stream is destroyed in the InkUI dispose path, but Bun's + // libuv handle for it can linger past `destroy()` on Darwin + // (oven-sh/bun#29126), keeping the event loop ref'd so the + // process hangs until the user presses a key. // // The .unref() timer doesn't hold the loop itself, so it's a no-op - // in the happy path (Linux: no workaround installed, loop drains - // naturally; `--yes` on Darwin: no prompts, no keystroke issue, - // may still drain naturally). On the Darwin hang path, it - // force-exits after a 100ms grace window — imperceptible to the - // user and enough for Sentry telemetry + stdio flushes to - // complete first. + // in the happy path (Linux: handle drains naturally; `--yes` + // on Darwin: LoggingUI doesn't open /dev/tty, may still drain + // naturally). On the Darwin hang path, it force-exits after a + // 100ms grace window — imperceptible to the user and enough + // for Sentry telemetry + stdio flushes to complete first. // // Skipped under `bun test` (which sets NODE_ENV=test automatically) // because the test runner calls `initCommand.func` directly; an diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index f0f41f6b1..7d342211e 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -152,6 +152,22 @@ export function App({ store }: AppProps): React.ReactNode { const { columns, rows } = useTerminalSize(); const showSidebar = columns >= SIDEBAR_BREAKPOINT; + // Global Ctrl+C catcher. In raw mode Node doesn't emit SIGINT for + // `\x03` — Ink delivers it as `input === "c"` with `key.ctrl` set + // when a `useInput` listener is mounted. Each prompt's own + // `useInput` already handles cancellation, but during a spinner + // (no prompt) there's no input listener at all, so Ctrl+C would + // otherwise be silently dropped. This top-level listener fills + // that gap by exiting the process cleanly. Active prompts also + // see the same input event (Ink dispatches to all `useInput` + // listeners), and their `prompt.resolve(null)` runs before this + // exit so the wizard runner's WizardCancelledError propagates. + useInput((input, key) => { + if (key.ctrl && input === "c" && !snapshot.prompt) { + process.exit(130); + } + }); + return ( { + useInput((input, key) => { if (key.upArrow) { setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); return; @@ -511,9 +527,11 @@ function SelectPrompt({ setHighlighted((idx) => (idx + 1) % totalCount); return; } - if (key.escape) { - // Cooperative cancel — resolves the prompt with `null`, which - // the bridge translates to `CANCELLED`. + if (key.escape || (key.ctrl && input === "c")) { + // Cooperative cancel — Esc, or Ctrl+C in raw mode where Node + // doesn't deliver SIGINT. Resolves the prompt with `null`, + // which the bridge translates to `CANCELLED` and the wizard + // runner unwinds via `WizardCancelledError`. prompt.resolve(null); return; } @@ -612,9 +630,10 @@ function MultiSelectPrompt({ setHighlighted((idx) => (idx + 1) % totalCount); return; } - if (key.escape) { - // Cooperative cancel — resolves with `null`, which the bridge - // translates to `CANCELLED`. + if (key.escape || (key.ctrl && input === "c")) { + // Cooperative cancel — Esc, or Ctrl+C in raw mode where Node + // doesn't deliver SIGINT. Resolves with `null`, which the + // bridge translates to `CANCELLED`. prompt.resolve(null); return; } diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 299c35257..796cc4a55 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -11,27 +11,41 @@ * * Why Ink rather than OpenTUI? * - * - **Runs on Node.** OpenTUI's renderer is Zig-compiled and only - * loadable from Bun's `bun:ffi`. The npm/Node distribution of - * the CLI couldn't use it, so half the user base got a - * plain-text fallback. Ink is pure JS + React, so this same - * UI runs everywhere the CLI does. * - **No native binary cost.** The OpenTUI implementation added * ~10.7 MB to the compiled Bun binary (the `libopentui.so` - * plus the ~12k-line generated FFI bindings). Ink + companions - * add ~1–2 MB and are pure JS, so they bundle cleanly. + * plus the ~12k-line generated FFI bindings). Ink is pure JS, + * so it bundles cleanly with no platform-specific peer + * packages. * - **Inline rendering.** Ink writes incrementally to stdout, so * log lines naturally end up in the user's scrollback. OpenTUI * needed an alternate-screen buffer + a post-dispose stderr * replay to leave any trace of the run behind. * - * **Lazy import.** `ink`, `ink-spinner`, `ink-select-input`, and - * `react` are all dynamically imported by `createInkUI()` so the - * npm bundle (which excludes them from the bundle graph) never sees - * the imports at module-load time. This keeps the `LoggingUI` path - * cheap to instantiate when interactive UI is not needed. + * **Stdin workaround for Bun.** Ink listens for `readable` events + * on its `stdin` option (default `process.stdin`) and calls + * `stdin.read()` to consume bytes. Bun's compiled binaries have a + * long-standing bug — `process.stdin` accepts `setRawMode(true)` but + * never delivers `readable` events for terminal input + * (oven-sh/bun#6862, vadimdemedes/ink#636, both still open). The + * symptom: the wizard renders fine but arrow keys, Enter, and + * Ctrl+C all do nothing. + * + * Workaround: open a fresh `/dev/tty` `ReadStream` ourselves and + * pass it to Ink as the `stdin` option. The fresh stream's + * `readable` events fire correctly because the file-descriptor + * inheritance bug only affects fd 0, not fds we open inside the + * process. We close the stream on dispose to release the libuv + * handle. + * + * **Lazy import.** `ink`, `ink-spinner`, and `react` are all + * dynamically imported by `createInkUI()` so the npm bundle (which + * excludes them from the bundle graph) never sees the imports at + * module-load time. This keeps the `LoggingUI` path cheap to + * instantiate when interactive UI is not needed. */ +import { openSync } from "node:fs"; +import { ReadStream } from "node:tty"; import chalk from "chalk"; import { stripAnsi } from "../../formatters/plain-detect.js"; import { buildFileTree, flattenTree } from "./file-tree.js"; @@ -131,6 +145,22 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { // @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun import inkAppPath from "./ink-app.tsx" with { type: "file" }; +/** + * Open a fresh `/dev/tty` `ReadStream` for Ink to consume. Returns + * `null` when `/dev/tty` isn't available (non-TTY environment, or + * platforms that don't expose it — Windows). The caller falls back + * to `process.stdin` in that case, which works on Node but is + * broken in Bun-compiled binaries (see module docstring). + */ +function openFreshTtyForInk(): ReadStream | null { + try { + const fd = openSync("/dev/tty", "r"); + return new ReadStream(fd); + } catch { + return null; + } +} + /** * Async factory for `InkUI`. Imports `ink`, `react`, and the local * `App` component lazily, mounts the React tree, and returns the @@ -161,6 +191,13 @@ export async function createInkUI(): Promise { })), }); + // Open a fresh /dev/tty so Ink's `readable` event listener + // actually fires — see the module docstring for the Bun bug + // details. We hold onto the stream so we can close it on dispose + // (libuv otherwise keeps the handle alive and the process can't + // exit cleanly). + const freshStdin = openFreshTtyForInk(); + // Ink's render returns a handle with `unmount()` and // `waitUntilExit()`. We don't await `waitUntilExit` here because // the wizard drives lifecycle imperatively from the runner; the @@ -168,18 +205,30 @@ export async function createInkUI(): Promise { // finishes (success or failure). // // `exitOnCtrlC: false` lets us route Ctrl+C through the prompt - // cancellation path (`installCancelHandler`) instead of yanking - // the process down mid-spinner. + // cancellation path (the SelectPrompt / MultiSelectPrompt + // `useInput` handlers detect `\x03` and resolve with `null`) + // instead of yanking the process down mid-spinner. // // `patchConsole: false` keeps `console.*` calls flowing to the // real stdout — Sentry SDK breadcrumbs, debug logs, etc. would // otherwise be swallowed by Ink's render loop. - const instance = ink.render(react.createElement(app.App, { store }), { + const renderOptions: { + exitOnCtrlC: boolean; + patchConsole: boolean; + stdin?: ReadStream; + } = { exitOnCtrlC: false, patchConsole: false, - }); + }; + if (freshStdin) { + renderOptions.stdin = freshStdin; + } + const instance = ink.render( + react.createElement(app.App, { store }), + renderOptions + ); - return new InkUI(instance, store); + return new InkUI(instance, store, freshStdin); } /** @@ -209,6 +258,14 @@ type InkInstance = { export class InkUI implements WizardUI { private readonly instance: InkInstance; private readonly store: WizardStore; + /** + * Fresh `/dev/tty` stream Ink reads from. We own this — closing + * it on dispose lets the libuv handle drain so `process.exit` (or + * a natural exit) actually fires. `null` when `/dev/tty` couldn't + * be opened (Windows, sandboxed environments) — Ink falls back to + * `process.stdin` in that case. + */ + private readonly freshStdin: ReadStream | null; private tipTimer: ReturnType | undefined; private tipIndex = 0; private activePromptCancel: (() => void) | undefined; @@ -225,9 +282,14 @@ export class InkUI implements WizardUI { private outroMessage: string | undefined; private failureMessage: string | undefined; - constructor(instance: InkInstance, store: WizardStore) { + constructor( + instance: InkInstance, + store: WizardStore, + freshStdin: ReadStream | null + ) { this.instance = instance; this.store = store; + this.freshStdin = freshStdin; this.startTipRotation(); this.installCancelHandler(); } @@ -414,6 +476,24 @@ export class InkUI implements WizardUI { } catch { // Ignore — disposal must never throw. } + if (this.freshStdin) { + // Restore termios before destroying the stream — Ink may have + // left raw mode enabled if `useInput` was active when we + // unmounted. Without this the user's shell shows an echo-less + // session after a crash. Best-effort: the stream may already + // be torn down from a prior error. + try { + this.freshStdin.setRawMode(false); + } catch { + // intentionally empty — stream already closed + } + try { + this.freshStdin.pause(); + this.freshStdin.destroy(); + } catch { + // intentionally empty + } + } const report = this.buildPostDisposeReport(); if (report) { process.stderr.write(`${report}\n`); @@ -488,16 +568,16 @@ export class InkUI implements WizardUI { } /** - * Wire the global Ctrl+C / Escape handler. Cooperative - * cancellation — resolve the active prompt with `CANCELLED` - * rather than yanking the process down, so `wizard-runner.ts` - * can drive its normal cleanup path (telemetry, exit code, etc.). + * Fallback SIGINT handler for the (rare) windows where raw mode + * is OFF and Node's terminal layer DOES deliver SIGINT for + * Ctrl+C. The primary Ctrl+C handling lives inside Ink's + * `useInput` (see `ink-app.tsx`'s top-level App component): in + * raw mode, Node sends `\x03` as a byte instead of SIGINT. * - * Ink's `useInput` only fires inside a focused component; we want - * cancellation to work even when no prompt is mounted (e.g. - * during a spinner). Hook into the process-level SIGINT instead - * — `exitOnCtrlC: false` on the render call ensures Ink doesn't - * intercept first. + * This handler covers the brief window between InkUI + * construction and the first `useInput` listener being mounted, + * plus any time raw mode flickers off (Ink toggles it in a + * useEffect when the listener count drops to zero). */ private installCancelHandler(): void { const handler = () => { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index d86d1df1a..1ddae55a3 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -41,7 +41,7 @@ import { formatError, formatResult } from "./formatters.js"; import { checkGitStatus } from "./git.js"; import { handleInteractive } from "./interactive.js"; import { resolveInitContext } from "./preflight.js"; -import { forwardFreshTtyToStdin } from "./stdin-reopen.js"; + import { describeTool, executeTool } from "./tools/registry.js"; import type { ResolvedInitContext, @@ -415,28 +415,22 @@ async function preamble( // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential wizard orchestration with error handling branches export async function runWizard(initialOptions: WizardOptions): Promise { - // macOS-only: Bun's compiled binaries on Darwin don't deliver keystrokes - // through TTY fds inherited via shell redirection (`curl | bash` → - // `exec sentry init { - // intentionally empty — workaround not installed on this platform - }, - }; + // The `forwardFreshTtyToStdin` function is preserved in + // `stdin-reopen.ts` for future callers (and its tests) but no + // longer wired into the wizard. const { directory, yes, dryRun, features, forceLegacyUi } = initialOptions; From 4a3e83547961878d2004e01e52cfd1c73b33e572 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:26:30 +0000 Subject: [PATCH 4/9] fix(init): clear screen on dispose + tighten sidebar layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visual fixes called out by the user: 1. **Clear the wizard chrome before printing the post-dispose summary.** Previously the bordered wizard box stayed on screen above the chalk summary, which was redundant and visually noisy. `instance.clear()` now runs immediately before `unmount()` so Ink rewinds the cursor and overwrites the rendered region; the post-dispose `Setup complete` line + summary becomes the only thing left on screen. The summary now writes to stdout (was stderr) so it lands in the same stream as the cleared Ink output — avoids potential interleave issues when the user pipes stdout/stderr separately. 2. **Tighten sidebar spacing.** The three sidebar panels (TipPanel, ProgressPanel, FilesPanel) had a `gap={1}` between them, plus 1-row inner margins between each panel's title and body. That was ~7 wasted rows on a typical run. Removed: - The outer `gap={1}` between panels (now flush borders). - `marginBottom={1}` after each panel title. - `marginTop={1}` between TipPanel body and counter. Tip-card body and counter are now stacked directly via the normal flex flow; the rounded border + `paddingX={1}` already provides enough visual separation. The `Did you know?` heading moved into the bottom counter row (`Tip 3 of 12 · Did you know?`) so the title row isn't wasted on a static label that never changed. 3. **Better files-panel truncation indicator.** The "scroller" the user asked for can't be a real interactive scroller — Ink doesn't ship a scrollbox primitive, the file tree updates frequently (new reads push the bottom), and adding `useInput` to the panel would compete with the active prompt for key events. Instead the tail-window UX is preserved with a clearer indicator: `↑ N earlier (scrolled)` at the top when rows are off-screen, and the panel header already shows `Files analyzed (n/total)` so the user sees the full count. Reserving 1 row for the header inside the maxRows budget means the actual file-row count is honoured (previously the header could squeeze the last visible file row). Verification: - bun run typecheck (clean) - bun x ultracite check (1 pre-existing warning, no new ones) - bun test --isolate test/lib/init/ (227 pass) - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, no size regression) - Smoke test confirmed: post-dispose summary stands alone, no wizard box above it. --- src/lib/init/ui/ink-app.tsx | 85 +++++++++++++++++++++++-------------- src/lib/init/ui/ink-ui.ts | 28 ++++++++++-- 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 7d342211e..7dc668605 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -712,17 +712,21 @@ function Sidebar({ filesRead: FileReadEntry[]; terminalRows: number; }): React.ReactNode { - // Reserve space for the tip card (12 rows incl. border + padding) - // and the progress checklist (steps + 4 rows of border + title). - // Whatever remains, capped at MAX_FILE_ROWS, goes to the files panel. - const tipReserved = 12; - const progressReserved = steps.length + 4; + // Reserve space for the tip card (~9 rows including its border) + // and the progress checklist (steps + 3 rows of border + title). + // Whatever remains, clamped between MIN/MAX_FILE_ROWS, goes to + // the files panel as its viewport. + const tipReserved = 9; + const progressReserved = steps.length + 3; const fileBudget = Math.max( MIN_FILE_ROWS, Math.min(MAX_FILE_ROWS, terminalRows - tipReserved - progressReserved - 2) ); + // No `gap` between panels — the rounded borders touch edge-to-edge, + // which reads as a single chrome region rather than three floating + // cards with empty rows between them. return ( - + @@ -734,6 +738,10 @@ function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; const total = SENTRY_TIPS.length; const oneIndexed = (tipIndex % total) + 1; + // The rounded box's top border carries the title (Ink's `title` + // prop). Body and counter follow with no inner margins — the + // border + 1-cell padding on each side already separates the + // content from the chrome. return ( - - - Did you know? - - - {tip.title} - - {tip.body} - - - - Tip {oneIndexed} of {total} - - + + {tip.title} + + {tip.body} + + Tip {oneIndexed} of {total} · Did you know? + ); } @@ -783,11 +784,9 @@ function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { flexShrink={0} paddingX={1} > - - - Progress ({completedCount}/{totalCount}) - - + + Progress ({completedCount}/{totalCount}) + {steps.map((entry) => ( ))} @@ -845,6 +844,19 @@ function progressStyle(entry: StepEntry): { * Hidden until at least one file has been recorded — the empty box * would just be visual noise during the auth/discover phase. */ +/** + * Render the read-files tree inside a fixed-height viewport that + * acts like a tail-`f` window: the most recent rows are always + * visible, with a `↑ N earlier` indicator at the top when older + * rows have scrolled out of view. + * + * Why no real scroller? Ink doesn't ship a native scrollbox + * primitive, and a third-party one would mean wiring focus + * management (PgUp/PgDn while a prompt is mounted, etc.) — too + * much complexity for what's effectively a status indicator. + * Tail-window UX matches what the user actually wants: see what + * the wizard is reading right now. + */ function FilesPanel({ filesRead, maxRows, @@ -857,8 +869,17 @@ function FilesPanel({ } const tree = buildReadTree(filesRead); const rows = flattenTree(tree); - const truncated = rows.length > maxRows; - const visible = truncated ? rows.slice(rows.length - maxRows) : rows; + // The header takes 1 row of the panel's vertical budget; reserve + // it so the file rows don't get squeezed. + const fileRowBudget = Math.max(1, maxRows - 1); + const truncated = rows.length > fileRowBudget; + // When truncated, the truncation indicator itself takes one row, + // so the actual visible file count is one less. + const visibleFileRows = truncated ? fileRowBudget - 1 : fileRowBudget; + const visible = truncated + ? rows.slice(rows.length - visibleFileRows) + : rows; + const hidden = rows.length - visible.length; const analyzedCount = filesRead.filter( (entry) => entry.status === "analyzed" ).length; @@ -870,13 +891,11 @@ function FilesPanel({ flexShrink={0} paddingX={1} > - - - Files analyzed ({analyzedCount}/{filesRead.length}) - - + + Files analyzed ({analyzedCount}/{filesRead.length}) + {truncated ? ( - … {rows.length - maxRows} earlier + ↑ {hidden} earlier (scrolled) ) : null} {visible.map((row, i) => ( // Tree rows are positionally stable for a given filesRead diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 796cc4a55..21dfbe052 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -238,14 +238,21 @@ export async function createInkUI(): Promise { * dynamic-import boundary in `createInkUI` doesn't leak Ink types * into the rest of the bridge module. `rerender` takes * `react.ReactNode` upstream; we widen it to a generic function - * type and only ever call `unmount`/`waitUntilExit` from the bridge - * anyway. + * type and only ever call `unmount`/`waitUntilExit`/`clear` from + * the bridge anyway. */ type InkInstance = { unmount: () => void; waitUntilExit: () => Promise; // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary rerender: (node: any) => void; + /** + * Clears Ink's last rendered output from the terminal. We call + * this on dispose so the final post-dispose chalk summary is + * the only thing left on screen — without it the bordered + * wizard box stays above the summary, which looked redundant. + */ + clear: () => void; }; // ──────────────────────────── Implementation ────────────────────────── @@ -471,6 +478,17 @@ export class InkUI implements WizardUI { process.removeListener("SIGINT", this.cancelHandler); this.cancelHandler = undefined; } + // Clear Ink's last rendered output BEFORE unmount so the + // bordered wizard box doesn't linger above the post-dispose + // chalk summary. `clear()` rewinds the cursor to the top of + // Ink's output region and overwrites the rows with blanks; + // the subsequent stderr write places the summary at that + // position, becoming the only visible chrome. + try { + this.instance.clear(); + } catch { + // Ignore — clear is best-effort. + } try { this.instance.unmount(); } catch { @@ -496,7 +514,11 @@ export class InkUI implements WizardUI { } const report = this.buildPostDisposeReport(); if (report) { - process.stderr.write(`${report}\n`); + // Write to stdout (not stderr) so the summary lands in the + // same stream as the cleared Ink output. Mixing stderr in + // would risk an extra line break or out-of-order interleave + // depending on shell pipe handling. + process.stdout.write(`${report}\n`); } return Promise.resolve(); } From ddf8cb9cf22b8d0f0a3a4773234de3d303990819 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:48:19 +0000 Subject: [PATCH 5/9] chore(init): collapse FilesPanel ternary to satisfy biome formatter CI's stricter biome version flagged the multi-line ternary in `FilesPanel`'s `visible` assignment. Auto-fixed by `bun x biome format --write`. --- src/lib/init/ui/ink-app.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 7dc668605..f1f7312af 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -876,9 +876,7 @@ function FilesPanel({ // When truncated, the truncation indicator itself takes one row, // so the actual visible file count is one less. const visibleFileRows = truncated ? fileRowBudget - 1 : fileRowBudget; - const visible = truncated - ? rows.slice(rows.length - visibleFileRows) - : rows; + const visible = truncated ? rows.slice(rows.length - visibleFileRows) : rows; const hidden = rows.length - visible.length; const analyzedCount = filesRead.filter( (entry) => entry.status === "analyzed" From d59356da8af1b7bcc5d8853a3bef0f07657c0f5f Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:43:03 +0000 Subject: [PATCH 6/9] fix(init): cooperative Ctrl+C cancellation + sidebar visual polish Addresses two HIGH-severity bug-prediction findings on PR #885 and restores the visual polish that the OpenTUI version had: 1. Ctrl+C during a spinner no longer calls process.exit(130) directly. The App's top-level useInput now routes through store.requestCancel(), which the InkUI bridge wires to a single requestCancel() entry point. That entry point either delegates to an active prompt's cancel callback (preserving the existing WizardCancelledError flow) or runs an idempotent tearDown() followed by process.exit(130) on the no-prompt path. 2. The SIGINT handler now funnels through the same requestCancel() so terminal restoration, /dev/tty release, post-dispose summary emission, and exit code are uniform across all three cancel entry points (App useInput, SIGINT, prompt). Switched process.on -> process.once so a stuck teardown can't hold the user hostage if Ctrl+C is pressed twice. 3. Two idempotency guards (torndown, cancelRequested) make tearDown() and the no-prompt branch of requestCancel() safe to call from multiple paths racing each other. Visual polish: - Divider now tracks main-column width (passed via prop) instead of hard-coding ".repeat(50)", so it doesn't truncate when the sidebar is visible nor look stubby on wide terminals. Capped at 56 to match banner row width. - Sidebar panel headers (TipPanel, ProgressPanel, FilesPanel) use bold-muted eyebrow + right-aligned counter pattern instead of a bold-accent title row. Reads as proper section chrome rather than competing with the actual content highlight (tip title in ACCENT) for the eye. - TipPanel counter moved to right-aligned bottom row so "Tip n of N" doesn't share a line with "Did you know?" eyebrow. Tests added for WizardStore.setRequestCancel covering initial state, registration, idempotency by reference, clearing on teardown, and round-trip invocation. --- AGENTS.md | 43 ++++---- src/lib/init/ui/ink-app.tsx | 133 +++++++++++++++------- src/lib/init/ui/ink-ui.ts | 152 +++++++++++++++++++++----- src/lib/init/ui/wizard-store.ts | 41 +++++-- test/lib/init/ui/wizard-store.test.ts | 63 +++++++++++ 5 files changed, 333 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b1b53829..924ef10c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -991,6 +991,9 @@ mock.module("./some-module", () => ({ ### Architecture + +* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard widget interval from terminal width: \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\` calculates chart interval before API calls. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\`, \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry bucket (1m–1d). \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\`. + * **DSN org prefix normalization in arg-parsing.ts**: DSN/numeric org prefix normalization — four code paths must all convert to slugs before API calls (many endpoints reject numeric org IDs with 404/403): (1) \`extractOrgIdFromHost\` strips \`o\` prefix during DSN parsing. (2) \`stripDsnOrgPrefix()\` handles user-typed \`o1081365/\` in \`parseOrgProjectArg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` resolves bare numeric IDs via DB cache or uncached API call. (4) Dashboard's \`resolveOrgFromTarget()\` pipes through \`resolveEffectiveOrg()\`, also used by \`tryResolveRecoveryOrg()\` in hex-id-recovery. @@ -1013,13 +1016,13 @@ mock.module("./some-module", () => ({ * **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Issue stats and list layout: \`stats\` depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). Critical: \`count\` is period-scoped — use \`lifetime.count\` for true total. \`--compact\` is tri-state (\`optional: true\`): explicit overrides, \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. TREND column hidden < 100 cols. Stricli boolean flags with \`optional: true\` produce \`boolean | undefined\` enabling this auto-detect pattern. -* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, etc.) live in \`hex-id.ts\`. +* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, \`LEADING\_HEX\_RE\`, \`MIDDLE\_ELLIPSIS\_RE\`, \`HexEntityType\`) live in \`hex-id.ts\`. - -* **Shared meta-table formatting helpers in src/lib/formatters/meta-table.ts**: \`src/lib/formatters/meta-table.ts\` provides \`buildMetaColumns(fieldNames, fieldTypes?, fieldUnits?)\` and \`formatCellValue(value, fieldType?, unit?)\` for rendering tables from Sentry events/explore API responses with \`meta.fields\`/\`meta.units\`. Numeric field types (\`integer\`, \`number\`, \`duration\`, \`percentage\`, \`size\` — exported as \`NUMERIC\_FIELD\_TYPES\` Set) are right-aligned. Cell formatting: \`duration\`/\`size\` use \`appendUnitSuffix(formatNumber(v), unit)\`; \`percentage\` multiplies by 100 (note: \`fmtPct\` in \`numbers.ts\` does NOT). Use this for any command rendering Sentry events-API tabular output (explore, future span/transaction list refactors) instead of duplicating the type-switch logic. + +* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli error gaps: (1) Route failures uninterceptable — Stricli writes stderr and returns \`ExitCode.UnknownCommand\` (-5 / 251 in Bun); only post-\`run()\` \`process.exitCode\` check works. (2) \`OutputError\` calls \`process.exit()\` immediately, bypassing telemetry. (3) \`defaultCommand: 'help'\` bypasses built-in fuzzy matching — fixed by \`resolveCommandPath()\` in \`introspect.ts\` using \`fuzzyMatch()\` (up to 3 suggestions); JSON includes \`suggestions\`. (4) Plural alias detection in \`app.ts\`. -* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry span APIs with different attribute capabilities: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` enumerates requested attrs; returns \`measurements\` (zero-filled on non-browser, stripped by \`filterSpanMeasurements()\`). (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span full detail; ALL attributes as \`{name,type,value}\[]\`. (3) \`/events/?dataset=spans\&field=X\` — list/search; explicit \`field\` params. Note: \`meta.fields\` returned in non-deterministic order, so derive column order from user's \`--field\`/\`-F\` list, not \`Object.keys(meta.fields)\` — see \`orderFieldNames()\` in \`src/commands/explore.ts\`. Affects dashboard widgets, span list, transactions list. \`--fields\` flag filters JSON output AND requests extra API fields via \`extractExtraApiFields()\`; \`FIELD\_GROUP\_ALIASES\` supports shorthand. +* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry span APIs with different attribute capabilities: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` enumerates requested attrs; returns \`measurements\` (zero-filled on non-browser, stripped by \`filterSpanMeasurements()\`). (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span full detail; ALL attributes as \`{name,type,value}\[]\` automatically. (3) \`/events/?dataset=spans\&field=X\` — list/search; explicit \`field\` params. \`--fields\` flag filters JSON output AND requests extra API fields via \`extractExtraApiFields()\`; \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion. ### Decision @@ -1034,14 +1037,11 @@ mock.module("./some-module", () => ({ ### Gotcha - -* **api.ts: plain Error throws inside func() bypass CliError handling**: api.ts plain Error throws bypass CliError handling: \`src/commands/api.ts\` throws plain \`new Error(...)\` in user-input validation paths called from \`func()\` (\`buildBodyFromInput\` file-not-found, \`parseHeaders\`, \`parseFieldKey\`/\`validatePathSegments\`/\`validateTypeCompatibility\`). Plain \`Error\` falls through \`app.ts\`'s \`instanceof CliError\` check → user sees \`Unexpected error:\` with stack trace AND it's reported to Sentry as a CLI bug (per \[\[019d799a-4809-7c54-b699-e2ae74c00227]]). Fix: use \`ValidationError\` (with \`field\` metadata) for user-input errors thrown inside \`func()\`. Plain \`Error\` is only OK in Stricli \`parse:\` callbacks where Stricli catches them. CLI-1GC tracks the \`--input\` file-not-found case. - -* **Biome lint differs between local lint:fix and CI lint**: Biome lint gotchas — \`lint:fix\` hides CI issues, always run \`bun run lint\` before pushing: (1) \`noPrecisionLoss\` on int literals >2^53 — use \`Number(string)\`. (2) \`noIncrementDecrement\` — use \`i += 1\`. (3) \`noUselessUndefined\` rewrites \`() => undefined\` → \`() => {}\`, then trips \`noEmptyBlockStatements\` — declare top-level \`function noop() {}\`. (4) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers, don't biome-ignore (adding branches to existing functions like \`formatHelpHuman\` easily pushes over). (5) \`noShadow\` flags overload signature param names matching impl. (6) \`noMisplacedAssertion\` flags \`expect()\` in test helpers — needs per-line ignore. (7) Biome-ignore on \`as any\` flagged \`suppressions/unused\` unless rule fires. Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`. Namespace imports forbidden. +* **Biome lint differs between local lint:fix and CI lint**: Biome \`lint:fix\` (local) differs from CI \`lint\` — auto-fix can hide issues CI still catches: (1) \`noPrecisionLoss\` on integer literals >2^53, (2) \`noIncrementDecrement\` on \`count++\`, (3) import ordering when a named import follows non-import runtime code. Formatter rewrites multi-line imports to single-line when they fit. Always run \`bun run lint\` before pushing. Use \`for...of\` destructuring or \`i += 1\` instead of \`++\`; use \`Number(string)\` or split literals instead of \`1\_735\_689\_600\_000\_000\_001\`. - -* **buildCommand wrapper: loader() returns wrapped async fn, not the generator**: \`cmd.loader()\` from \`buildCommand\` returns the \*wrapped\* async fn (\`wrappedFunc\` in \`src/lib/command.ts:542\`), not the original \`async \*func()\`. Wrapper iterates generator internally and writes to \`ctx.stdout\`. Tests: \`await func.call(ctx, flags, ...args)\` like a promise — don't iterate as generator. Errors propagate as rejected promises. Auth guard runs first: \`if (requiresAuth && !getAuthConfig()) throw new AuthError("not\_authenticated")\` — default \`auth: true\`. \`test/preload.ts:100\` sets fake \`SENTRY\_AUTH\_TOKEN\` so guard passes. Tests must save/restore only env vars they mutate. \`ctx.process.\*\` is dead code — wrapper reads only \`this.stdout\`/\`this.stderr\`. + +* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins (e.g. \`node:tty\`) needs \`default\` re-export plus named exports, declared top-level BEFORE \`await import()\`; lives in \`test/isolated/\`. (2) Destructuring imports capture binding at load; verify via call-count > 0. (3) \`Bun.mmap()\` always opens PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (4) Wrap \`Bun.which()\` with optional \`pathEnv\` for deterministic testing. (5) Mocking \`@sentry/node-core/light\`: \`startSpan\` must pass mock span to callback — \`startSpan: (\_, fn) => fn({ setStatus(){}, setAttribute(){}, end(){} })\`. ### Pattern @@ -1054,24 +1054,23 @@ mock.module("./some-module", () => ({ * **fetchWithTimeout uses bare fetch reference for test mockability**: \`fetchWithTimeout\` in \`src/lib/sentry-client.ts\` calls \`fetch(input, ...)\` as a bare global reference — this is load-bearing for tests that swap \`globalThis.fetch\`. Do NOT refactor to capture \`fetch\` at module load (via destructuring or aliasing) — all tests using \`mockFetch()\` would silently fall through to real network. \`resetAuthenticatedFetch()\` in test \`beforeEach\` clears the authenticated-fetch singleton (for auth state), NOT the fetch mock itself. If refactoring, add explicit \`// must remain bare fetch() for test mockability\` comment. - -* **Identity-scoped response cache via fingerprint mixin**: Identity-scoped response cache (\`src/lib/response-cache.ts\`): \`buildCacheKey(method, url)\` mixes memoized \`getIdentityFingerprint()\` (MD5 of \`kind|secret\`, 16 hex). \`CacheEntry\` persists identity so \`invalidateCachedResponsesMatching(prefix)\` skips other identities. TTL tiers (fallback when server sends no \`Cache-Control\`): \`no-cache\` (autofix polling), \`immutable\` 24h (events, traces/trace-items by ID), \`volatile\` 60s (issues, logs/transactions datasets, trace-logs), \`stable\` 5m (default). Entry shape: \`{policy, body, status, headers, url, identity?, createdAt, expiresAt?}\` — \`expiresAt\` pre-computed at write time. Invalidation centralized at \`authenticatedFetch\`: after 2xx non-GET, \`computeInvalidationPrefixes()\` walks hierarchy via \`buildApiUrl\`. \`SKIP\_INVALIDATION\_PATTERNS\` short-circuits chunk-upload/assemble. \`clearAuth()\` dynamically imports \`clearResponseCache\` to break cycle. Legacy entries lacking \`identity\` treated as foreign on prefix sweeps. + +* **I/O concurrency limits belong at the call site, not in generic combinators**: I/O concurrency limits belong at the call site, not in generic combinators. Pattern: module-scoped \`pLimit()\` with named constant (e.g., \`STAT\_CONCURRENCY = 32\` in \`project-root.ts\`, \`CACHE\_IO\_CONCURRENCY\` in \`response-cache.ts\`, \`pLimit(50)\` in \`code-scanner.ts\`). Keeps combinators pure, makes budget explicit at I/O boundary. stat() lighter than full reads — ~32 for stats vs ~50 for reads, well below macOS's 256 FD ceiling. - -* **Merging mock.module() test files with static-import counterparts**: Bun test mocking traps: (1) \`mock.module()\` for CJS built-ins (e.g. \`node:tty\`) needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) When merging mock.module() into static-import files, convert code-under-test to \`await import()\` — pre-existing static imports won't re-bind. (3) Destructured imports capture binding at load; verify via call-count. (4) \`Bun.mmap()\` always opens PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (5) Wrap \`Bun.which()\` with optional \`pathEnv\` for testing. (6) Mock \`@sentry/node-core/light\` \`startSpan: (\_, fn) => fn({ setStatus(){}, setAttribute(){}, end(){} })\`. + +* **Identity-scoped response cache via fingerprint mixin**: Identity-scoped response cache: \`buildCacheKey(method, url)\` mixes in memoized \`getIdentityFingerprint()\` (MD5 of \`kind|secret\` truncated to 16 hex; CodeQL dismissed — namespacing, not auth). \`CacheEntry\` persists identity so \`invalidateCachedResponsesMatching(prefix)\` skips other identities. Invalidation centralized at \`authenticatedFetch\` in \`sentry-client.ts\` — after 2xx non-GET, runs \`computeInvalidationPrefixes(fullUrl, getApiBaseUrl())\` walking hierarchy up to \`organizations/{org}/\` plus cross-endpoint rules via \`extra\`/\`extraAbsolute\` (control-silo vs region-silo). \*\*Contract: never throws\*\* — wrapped in try/catch. \`SKIP\_INVALIDATION\_PATTERNS\` short-circuits chunk-upload/assemble. \`clearAuth()\` dynamically imports \`clearResponseCache\` to break cycle. Always use prefix-match with trailing slash; exact-match removed. URL-only hook can't decode bulk mutations with IDs in query params (e.g. \`mergeIssues\`) — invalidate per-ID at caller. - -* **Shared pagination-hint builders: appendQueryHint, appendSortHint**: \`src/lib/list-command.ts\` exports small composable hint-flag helpers next to \`paginationHint\` and \`appendPeriodHint\` (in \`time-range.ts\`): \`appendQueryHint(parts, query)\` pushes \`-q "\"\` (always quoted for spaces); \`appendSortHint(parts, sort, defaultSort?)\` pushes \`--sort "\"\` only when sort differs from default — value MUST be quoted because aggregate sorts like \`-count()\` contain shell-special parens. Each list command still owns its \`appendFlagHints(base, flags)\` because flag sets differ (dataset, field, etc.), but should compose from these shared building blocks rather than open-coding \`parts.push(...)\`. + +* **Isolated adapter coverage via fetch mocking in test/lib/**: To get CodeCov coverage on API-calling functions (e.g., hex-id-recovery adapters, api-client functions), write tests in \`test/lib/\*.coverage.test.ts\` or \`test/lib/\*.adapters.test.ts\` that mock \`globalThis.fetch\` via \`mockFetch()\` from \`test/helpers.js\`, call \`setAuthToken()\` + \`setOrgRegion()\` in \`beforeEach\`, and invoke the REAL function. Tests in \`test/e2e/\` or tests that stub the exports via \`spyOn\`/\`mock.module\` give ZERO coverage to the mocked function body. Use \`useTestConfigDir()\` for DB isolation. Pattern example: \`test/lib/api-client.coverage.test.ts\` and \`test/lib/hex-id-recovery.adapters.test.ts\`. Mock responses must include ALL Zod-required fields — minimal stubs fail schema validation with a noisy \`ApiError\`. - -* **Sourcemap commands: discover-first read-only validation before mutation**: Sourcemap commands use discover-first read-only validation in \`src/lib/sourcemap/inject.ts\`: \`assertDirectoryReadable()\` + \`discoverFilePairs()\` run BEFORE \`injectDirectory()\` (writes debug IDs) and BEFORE \`resolveOrgAndProject()\`. Guarantees no file mutation on doomed runs. \`diagnoseEmptyDiscovery()\` + \`buildEmptyDiscoveryError()\` produce tailored errors (empty-dir vs JS-only vs maps-only vs basename-mismatch). Strict by default — zero pairs throws ValidationError; \`--allow-empty\` opts out. With \`--allow-empty\` and zero pairs, short-circuit before \`resolveOrgAndProject\` so library callers without DSN/org/project succeed silently. + +* **Memoize identity fingerprint with test-reset hook + setAuthToken invalidation**: Memoize + test-reset pattern in src/lib/db/auth.ts: \`getIdentityFingerprint()\`, \`getAuthToken()\` (as \`cachedAuthToken\`), and the full auth row used by \`refreshToken()\` (as \`cachedAuthRow\`) are all memoized at module scope. Use wrapper-object sentinels \`{ value }\` to distinguish 'not cached' from 'cached as undefined' (logged out). Invalidate via \`reset\*Cache()\` exports at the only mutation points: \`setAuthToken()\` and \`clearAuth()\`. Safe under OAuth rotation (refresh\_token preserved) and 401 refresh (routes through setAuthToken). Tests mutating \`process.env.SENTRY\_AUTH\_TOKEN\` bypass the mutation hooks — must call reset functions manually in beforeEach and inside property-test bodies. \`useTestConfigDir\` calls all three resets in beforeEach/afterEach to prevent cross-file pollution in Bun's sequential test runner. Same memo+reset pattern mirrors \`resetUpdateNotificationState\`, \`resetCacheState\`, \`resetAuthHintState\`. Fixed N+1 SQL hits per API request (CLI-13V). * **Stricli parse functions can perform validation and sanitization at flag-parse time**: Stricli's \`parse\` fn on \`kind: "parsed"\` flags runs during argument parsing before \`func()\`. Can throw (including \`ValidationError\`) and log warnings. Uses: \`parseCursorFlag\`, \`sanitizeQuery\`, \`parsePeriod\` (returns \`TimeRange\`), \`parseSort\`/\`parseSortFlag\`, \`numberParser\`/\`parseLimit\`. Optional period flags: \`flags.period\` is \`TimeRange | undefined\` — commands default to \`TIME\_RANGE\_\*\` constants. \`formatTimeRangeFlag()\` converts back; \`appendPeriodHint()\` in \`time-range.ts\` encapsulates hint-building across 4+ commands. - -* **URL-encoded paren assertions: decode before contains-check**: Test assertion gotchas for Sentry API URLs: (1) Parens in aggregate field names like \`count()\` become \`count%28%29\` via \`URLSearchParams\`/\`encodeURIComponent\` — use \`expect(decodeURIComponent(url)).toContain("field=count()")\` or assert against encoded form. Affects all Events API URL tests (\`field=count()\`, \`sort=-count()\`). (2) Sentry pagination Link header format: \`\; rel="next"; results="true"; cursor="0:50:0"\` — cursor is in a separate \`cursor="..."\` attribute, NOT embedded in the URL's query string. \`parseLinkHeader\` (re-exported from \`@sentry/api\` as \`parseSentryLinkHeader\` in \`src/lib/api/infrastructure.ts\`) extracts from the attribute. Tests mocking pagination must use the attribute form. +### Preference - -* **User-facing vs API-level dataset names: emit user-facing in pagination hints**: User-facing vs API-level dataset name reverse-mapping: When \`parseDataset\` resolves a user-facing alias (\`metrics\`) to API-level name (\`metricsEnhanced\`) in \`flags.dataset\`, pagination hints and headers must reverse-map back — emitting \`--dataset metricsEnhanced\` would fail validation if user copies the hint. Pattern in \`src/commands/explore.ts\`: \`VALID\_DATASETS\` is a \`Set\` (preserves insertion order, used for help/error join + reverse-map iteration); \`API\_TO\_USER\_DATASET\` is a \`Map\` built via \`Array.from(VALID\_DATASETS, name => \[DATASET\_ALIASES\[name] ?? name, name])\`. Use \`.get()\` in \`appendFlagHints\` and \`formatExploreHuman\`. Hidden/deprecated aliases (\`transactions\`, \`discover\`) stay parseable via \`DATASET\_ALIASES\` but excluded from \`VALID\_DATASETS\` so error messages only suggest active datasets. + +* **Code review style: BYK values brevity; trim JSDoc essays aggressively**: BYK code-review style — brevity first: terse 1-3 line JSDoc; remove comments that restate code; don't wrap try/catch around no-throw helpers (but DO wrap post-success housekeeping like cache invalidation — defense-in-depth); MD5 over HMAC for non-auth hashing; no lazy imports without documented reason. Prefer \`\[...new Set(items)]\` over hand-rolled dedupe; \`toSpliced\` over spread+new-array; spread/slice over \`.unshift()\` on returned API objects. Direct questions drive simplification ('inputs never change, why not memoize?' → memoize+reset). Dismiss CodeQL false positives via \`gh api\` with rationale. 'Centralized mechanism' → file follow-up issue, not scope creep. Implement trivial reviewer suggestions in-PR rather than deferring. Run subagent self-review on merge-ready PRs — typical yield 1-3 items (stale PR descriptions, CI-only lint, doc drift). Take bot findings (Cursor Bugbot, Seer) seriously even after self-review approval — expect 4-6 rounds on subtle Unicode/regex/error-handling PRs. diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index f1f7312af..c5a2c173c 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -158,13 +158,20 @@ export function App({ store }: AppProps): React.ReactNode { // `useInput` already handles cancellation, but during a spinner // (no prompt) there's no input listener at all, so Ctrl+C would // otherwise be silently dropped. This top-level listener fills - // that gap by exiting the process cleanly. Active prompts also - // see the same input event (Ink dispatches to all `useInput` - // listeners), and their `prompt.resolve(null)` runs before this - // exit so the wizard runner's WizardCancelledError propagates. + // that gap by routing through `store.requestCancel` — the bridge + // (`InkUI`) registers a callback that performs the full teardown + // sequence (clear → unmount → restore termios → destroy stdin → + // emit summary) before `process.exit(130)`. Calling + // `process.exit` directly here would skip that cleanup and leave + // the user's terminal in raw mode (#885 review). + // + // When a prompt IS active, `snapshot.prompt` is non-null and the + // prompt's own `useInput` already handles Ctrl+C via its + // resolve(null) cancellation path; we explicitly skip in that + // case so we don't double-fire. useInput((input, key) => { if (key.ctrl && input === "c" && !snapshot.prompt) { - process.exit(130); + snapshot.requestCancel?.(); } }); @@ -180,6 +187,12 @@ export function App({ store }: AppProps): React.ReactNode { bannerRows={snapshot.bannerRows} filesRead={snapshot.filesRead} logs={snapshot.logs} + mainColumnWidth={ + // 4 cols outer chrome (border + paddingX=1 each side); + // when the sidebar is visible, also subtract its width + // plus the row gap of 2 cols. + showSidebar ? columns - 4 - SIDEBAR_WIDTH - 2 : columns - 4 + } prompt={snapshot.prompt} showFileReadInline={!showSidebar} spinner={snapshot.spinner} @@ -240,6 +253,8 @@ type MainColumnProps = { spinner: SpinnerState; prompt: ActivePrompt | null; summary: WizardSummary | null; + /** Available width inside the main column, used by the divider. */ + mainColumnWidth: number; /** * Whether to render the inline file-read status row above the * spinner. We only show this when the sidebar is hidden (narrow @@ -256,6 +271,7 @@ function MainColumn({ spinner, prompt, summary, + mainColumnWidth, showFileReadInline, }: MainColumnProps): React.ReactNode { // Hide the file-read status once the wizard finishes — the summary @@ -265,7 +281,7 @@ function MainColumn({ return (
- + {logs.map((log) => ( @@ -298,10 +314,29 @@ function Header({ ); } -function Divider(): React.ReactNode { +/** + * Horizontal rule used to separate the banner from the log/spinner + * area. Width tracks the available main-column width so the rule + * doesn't truncate when the sidebar is visible (~36 cols + gap) + * nor look stubby when the main column has the full terminal. + * + * Width budget: + * - 4 cols outer chrome (1 border + 1 padding on each side) + * - 38 cols sidebar + gap when visible (`SIDEBAR_WIDTH + 2`) + * - 2 cols safety so the line never bleeds into the right border + * + * Capped at 56 so a ridiculously wide terminal still looks balanced + * (matches the banner row width of 55 chars). + */ +function Divider({ + mainColumnWidth, +}: { + mainColumnWidth: number; +}): React.ReactNode { + const width = Math.max(20, Math.min(mainColumnWidth - 2, 56)); return ( - {"─".repeat(50)} + {"─".repeat(width)} ); } @@ -738,10 +773,14 @@ function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; const total = SENTRY_TIPS.length; const oneIndexed = (tipIndex % total) + 1; - // The rounded box's top border carries the title (Ink's `title` - // prop). Body and counter follow with no inner margins — the - // border + 1-cell padding on each side already separates the - // content from the chrome. + // Three-row layout: + // 1. Section header (faint, eyebrow-style) — anchors the panel's + // identity without consuming the border real estate Ink + // can't draw a title onto. + // 2. Tip title (bold, accent) — the highlight row. + // 3. Tip body, then a right-aligned "Tip n of N" counter at the + // bottom so the counter doesn't compete with the title for + // the eye. return ( + + Did you know? + {tip.title} {tip.body} - - Tip {oneIndexed} of {total} · Did you know? - + + + Tip {oneIndexed} of {total} + + ); } @@ -776,6 +820,10 @@ function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { (entry) => entry.status === "completed" ).length; const totalCount = steps.length; + // Eyebrow header on the left, completion ratio right-aligned so + // the eye can scan one column for "where am I" and the other for + // "how far along". Matches the layout pattern used in TipPanel + // and FilesPanel. return ( - - Progress ({completedCount}/{totalCount}) - + + + Progress + + + {completedCount}/{totalCount} + + {steps.map((entry) => ( ))} @@ -828,11 +881,17 @@ function progressStyle(entry: StepEntry): { } /** - * Read-files tree. Ink doesn't have a scrollbox primitive, so when - * the tree exceeds `maxRows` we render the **last** N rows (a - * tail-`f`-style window). For most runs the tree fits without - * truncation; long analyze sequences just push older entries off - * the top while keeping the active reads visible. + * Read-files tree, rendered inside a fixed-height tail-`f`-style + * viewport: the most recent rows are always visible, with a + * `↑ N earlier` indicator at the top when older rows have scrolled + * out of view. + * + * Why no real scroller? Ink doesn't ship a native scrollbox + * primitive, and a third-party one would mean wiring focus + * management (PgUp/PgDn while a prompt is mounted, etc.) — too + * much complexity for what's effectively a status indicator. + * Tail-window UX matches what the user actually wants: see what + * the wizard is reading right now. * * Visual rules: * - Directories: muted gray box-drawing branches + name with `/`. @@ -844,19 +903,6 @@ function progressStyle(entry: StepEntry): { * Hidden until at least one file has been recorded — the empty box * would just be visual noise during the auth/discover phase. */ -/** - * Render the read-files tree inside a fixed-height viewport that - * acts like a tail-`f` window: the most recent rows are always - * visible, with a `↑ N earlier` indicator at the top when older - * rows have scrolled out of view. - * - * Why no real scroller? Ink doesn't ship a native scrollbox - * primitive, and a third-party one would mean wiring focus - * management (PgUp/PgDn while a prompt is mounted, etc.) — too - * much complexity for what's effectively a status indicator. - * Tail-window UX matches what the user actually wants: see what - * the wizard is reading right now. - */ function FilesPanel({ filesRead, maxRows, @@ -889,12 +935,15 @@ function FilesPanel({ flexShrink={0} paddingX={1} > - - Files analyzed ({analyzedCount}/{filesRead.length}) - - {truncated ? ( - ↑ {hidden} earlier (scrolled) - ) : null} + + + Files analyzed + + + {analyzedCount}/{filesRead.length} + + + {truncated ? ↑ {hidden} earlier : null} {visible.map((row, i) => ( // Tree rows are positionally stable for a given filesRead // snapshot — `buildReadTree` walks `filesRead` in insertion diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 21dfbe052..5d6795117 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -277,6 +277,22 @@ export class InkUI implements WizardUI { private tipIndex = 0; private activePromptCancel: (() => void) | undefined; private cancelHandler: (() => void) | undefined; + /** + * Guard so `tearDown()` runs at most once even when called from + * multiple paths (Ctrl+C in a spinner, then SIGINT, then + * `[Symbol.asyncDispose]` on the wizard-runner exit). Calling + * `unmount()` on an already-unmounted Ink instance throws on some + * Ink versions; running raw-mode restoration on a destroyed stream + * also throws. The flag short-circuits before either can happen. + */ + private torndown = false; + /** + * Guard so `requestCancel()` runs its no-active-prompt branch at + * most once. With this flag set, a subsequent Ctrl+C / SIGINT + * becomes a no-op rather than re-entering teardown — the user is + * already on the way out. + */ + private cancelRequested = false; /** * Final wizard outcome captured by the bridge. * @@ -299,6 +315,13 @@ export class InkUI implements WizardUI { this.freshStdin = freshStdin; this.startTipRotation(); this.installCancelHandler(); + // Hand the App a reference to `requestCancel` via the store so + // the top-level `useInput` Ctrl+C catcher in `ink-app.tsx` can + // route through the same teardown path as SIGINT and prompt + // cancellation. Without this the App would have to call + // `process.exit(130)` directly — bypassing termios restoration + // and leaking the `/dev/tty` handle. + this.store.setRequestCancel(() => this.requestCancel()); } // ── Lifecycle ───────────────────────────────────────────────────── @@ -470,6 +493,39 @@ export class InkUI implements WizardUI { // ── Disposal ────────────────────────────────────────────────────── [Symbol.asyncDispose](): Promise { + this.tearDown(); + return Promise.resolve(); + } + + /** + * Idempotent teardown. Safe to call from `[Symbol.asyncDispose]`, + * from `requestCancel()`, or from a SIGINT handler racing both. The + * `torndown` guard short-circuits second (and later) entries so we + * never call `unmount()` on an already-unmounted Ink instance or + * `setRawMode(false)` on an already-destroyed stream — both throw + * on some platforms. + * + * Order matters: + * 1. Stop the tip-rotation interval (libuv timer ref). + * 2. Detach SIGINT listener (we don't want a second Ctrl+C + * re-entering this path while we're in the middle of it). + * 3. `instance.clear()` — rewinds Ink's render region so the + * post-dispose chalk summary lands in place of the live + * wizard chrome rather than below it. + * 4. `instance.unmount()` — releases React reconciler resources. + * 5. Restore termios on the fresh `/dev/tty` stream, then + * `pause()` + `destroy()` so libuv can drain the handle and + * the process can exit naturally. + * 6. Emit the post-dispose summary to stdout (success outro or + * failure cancel line, matching the live screen's palette). + * + * Every step is wrapped in try/catch — disposal must never throw. + */ + private tearDown(): void { + if (this.torndown) { + return; + } + this.torndown = true; if (this.tipTimer) { clearInterval(this.tipTimer); this.tipTimer = undefined; @@ -478,38 +534,30 @@ export class InkUI implements WizardUI { process.removeListener("SIGINT", this.cancelHandler); this.cancelHandler = undefined; } - // Clear Ink's last rendered output BEFORE unmount so the - // bordered wizard box doesn't linger above the post-dispose - // chalk summary. `clear()` rewinds the cursor to the top of - // Ink's output region and overwrites the rows with blanks; - // the subsequent stderr write places the summary at that - // position, becoming the only visible chrome. + // Detach the cancel callback from the store so a stale Ctrl+C + // routed through the App after teardown can't re-enter. + this.store.setRequestCancel(undefined); try { this.instance.clear(); } catch { - // Ignore — clear is best-effort. + // best-effort } try { this.instance.unmount(); } catch { - // Ignore — disposal must never throw. + // best-effort } if (this.freshStdin) { - // Restore termios before destroying the stream — Ink may have - // left raw mode enabled if `useInput` was active when we - // unmounted. Without this the user's shell shows an echo-less - // session after a crash. Best-effort: the stream may already - // be torn down from a prior error. try { this.freshStdin.setRawMode(false); } catch { - // intentionally empty — stream already closed + // stream already torn down } try { this.freshStdin.pause(); this.freshStdin.destroy(); } catch { - // intentionally empty + // stream already destroyed } } const report = this.buildPostDisposeReport(); @@ -520,7 +568,59 @@ export class InkUI implements WizardUI { // depending on shell pipe handling. process.stdout.write(`${report}\n`); } - return Promise.resolve(); + } + + /** + * Cooperative cancellation entry point. Called from three places: + * + * 1. The App's top-level `useInput` Ctrl+C catcher (when no + * prompt is mounted — typically during a spinner / network + * call). Routed via `store.requestCancel()`. + * 2. The SIGINT process listener (covers raw-mode-off windows + * where Node delivers SIGINT instead of `\x03`). + * 3. (Indirectly) prompt cancellation, when an active prompt's + * own `useInput` resolves with `null`. That path doesn't go + * through `requestCancel` directly because the prompt's + * promise resolution drives the wizard runner's + * `WizardCancelledError` flow, which then runs + * `[Symbol.asyncDispose]` → `tearDown()` naturally. + * + * If a prompt IS active, we delegate to its cancel callback and + * return without exiting — the wizard runner will catch the + * resulting `WizardCancelledError` and exit cleanly via the + * `await using` path. + * + * If no prompt is active (spinner case), we tear down immediately + * and `process.exit(130)`. We can't route through the runner + * because it's blocked on `await executeTool(...)` or + * `await run.resumeAsync(...)` — there's nothing waiting to throw + * into. Exit code 130 is the SIGINT convention; the terminal is + * fully restored before exit so the user's shell prompt comes + * back cleanly. + * + * Idempotent: a second Ctrl+C while teardown is in progress is a + * no-op (the `cancelRequested` flag short-circuits). + */ + requestCancel(): void { + const promptCancel = this.activePromptCancel; + if (promptCancel) { + // Prompt path — let the runner unwind via WizardCancelledError. + // Don't tear down here; the `await using` in the runner will + // call us back through `[Symbol.asyncDispose]`. + promptCancel(); + return; + } + if (this.cancelRequested) { + return; + } + this.cancelRequested = true; + this.failureMessage = "Setup cancelled."; + this.tearDown(); + // Match the SIGINT convention so shells (and CI) see a + // distinguishable exit. The runner's `await using` won't get a + // chance to run after this, but tearDown above already did all + // the cleanup that path would have performed. + process.exit(130); } /** @@ -600,22 +700,20 @@ export class InkUI implements WizardUI { * construction and the first `useInput` listener being mounted, * plus any time raw mode flickers off (Ink toggles it in a * useEffect when the listener count drops to zero). + * + * Both this handler and the App's `useInput` Ctrl+C path funnel + * into `requestCancel()` so the cancellation flow has a single + * implementation. `process.once` rather than `process.on` so a + * second SIGINT arriving while teardown runs falls through to + * Node's default handler (immediate exit) — protects against a + * stuck teardown holding the user hostage. */ private installCancelHandler(): void { const handler = () => { - const cancelFn = this.activePromptCancel; - if (cancelFn) { - cancelFn(); - return; - } - // No active prompt — surface a clean cancel message so the - // wizard runner's catch-WizardCancelledError path triggers. - // We don't `process.exit` here; the caller decides. - this.failureMessage = "Setup cancelled."; - this.instance.unmount(); + this.requestCancel(); }; this.cancelHandler = handler; - process.on("SIGINT", handler); + process.once("SIGINT", handler); } } diff --git a/src/lib/init/ui/wizard-store.ts b/src/lib/init/ui/wizard-store.ts index d58282790..8cb0f619d 100644 --- a/src/lib/init/ui/wizard-store.ts +++ b/src/lib/init/ui/wizard-store.ts @@ -14,9 +14,6 @@ * The store is intentionally minimal: snapshots are plain immutable * objects so React's default `Object.is` reference check is enough * to detect changes. - * - * Originally written for OpenTUI; the data shape ported one-to-one to - * Ink because nothing here is specific to OpenTUI's component model. */ import { @@ -45,8 +42,9 @@ export type SpinnerState = { /** * One entry tracking a file the wizard has read from disk during the * session. Status transitions `reading` → `analyzed` once the tool - * returns. Surfaced by the inline file-read status line in `OpenTuiUI` - * (see `FileReadStatus` in `ink-app.tsx`). + * returns. Surfaced by the inline file-read status line and sidebar + * tree in the Ink app (see `FileReadStatus` and `FilesPanel` in + * `ink-app.tsx`). */ export type FileReadEntry = { path: string; @@ -124,7 +122,7 @@ export type WizardSnapshot = { * Persistent list of every file the wizard has read from disk. Each * entry carries a status that transitions `reading` → `analyzed` as * the workflow progresses. Surfaced by the inline file-read status - * line in `OpenTuiUI` so the user can see what context the wizard + * line in the Ink UI so the user can see what context the wizard * inspected — without the previous spinner-message approach, which * flashed each batch for half a second before the next tool * overwrote it. @@ -139,6 +137,17 @@ export type WizardSnapshot = { * `resolve-dir`) are silently ignored so the sidebar stays compact. */ steps: StepEntry[]; + /** + * Cancellation callback wired up by the bridge (`InkUI`) so the + * App's top-level Ctrl+C catcher can route through the same + * teardown path as SIGINT and prompt cancellation. `undefined` + * after teardown to short-circuit any stale events Ink might + * dispatch on the way out. + * + * Snapshotted on the store rather than passed via React props so + * the App doesn't have to thread a callback through every layer. + */ + requestCancel: (() => void) | undefined; }; export type Listener = () => void; @@ -168,6 +177,7 @@ export class WizardStore { label: shortStepLabel(id), status: "pending" as StepStatus, })), + requestCancel: initial.requestCancel, }; } @@ -243,6 +253,21 @@ export class WizardStore { this.update({ summary }); } + /** + * Register (or clear) the cooperative cancel callback. The Ink App + * subscribes to the snapshot and calls this from its top-level + * Ctrl+C `useInput` handler when no prompt is mounted (typical + * spinner / network-call window). Set to `undefined` on teardown + * so a stale event from Ink's render pipeline doesn't re-enter + * cancellation. + */ + setRequestCancel(callback: (() => void) | undefined): void { + if (this.snapshot.requestCancel === callback) { + return; + } + this.update({ requestCancel: callback }); + } + /** * Record that the wizard is currently reading a batch of files. * Existing entries (read in earlier batches) keep their status so @@ -341,8 +366,8 @@ export class WizardStore { } // Severity-to-prefix mapping kept here (alongside the entry type) so - // both the React renderer and the post-dispose stderr replay agree on - // the format. Used by `OpenTuiUI` when assembling its transcript. + // both the React renderer and the post-dispose summary report agree + // on the format. static prefixFor(severity: LogSeverity, code?: SpinnerExitCode): string { if (severity === "message") { return " "; diff --git a/test/lib/init/ui/wizard-store.test.ts b/test/lib/init/ui/wizard-store.test.ts index 14c63bbd8..f7b9620a0 100644 --- a/test/lib/init/ui/wizard-store.test.ts +++ b/test/lib/init/ui/wizard-store.test.ts @@ -148,3 +148,66 @@ describe("WizardStore step progress", () => { expect(notifications).toBe(2); }); }); + +/** + * Cancellation callback contract. + * + * The Ink App reads `snapshot.requestCancel` from inside its + * top-level `useInput` handler when no prompt is mounted (spinner + * window). The bridge (`InkUI`) registers the callback at + * construction and clears it on teardown so a stale Ink dispatch + * after unmount can't re-enter cancellation. + */ +describe("WizardStore.setRequestCancel", () => { + test("starts undefined so an early Ctrl+C is a no-op", () => { + const store = new WizardStore(); + expect(store.getSnapshot().requestCancel).toBeUndefined(); + }); + + test("registers a callback and exposes it on the snapshot", () => { + const store = new WizardStore(); + const cancel = () => { + /* no-op */ + }; + store.setRequestCancel(cancel); + expect(store.getSnapshot().requestCancel).toBe(cancel); + }); + + test("clears the callback on teardown by passing undefined", () => { + const store = new WizardStore(); + store.setRequestCancel(() => { + /* no-op */ + }); + store.setRequestCancel(undefined); + expect(store.getSnapshot().requestCancel).toBeUndefined(); + }); + + test("setting the same callback reference twice is a no-op", () => { + // Avoid React re-render churn when the bridge re-registers the + // same callback (idempotency for cheap callers). + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + const cancel = () => { + /* no-op */ + }; + store.setRequestCancel(cancel); + store.setRequestCancel(cancel); + unsubscribe(); + expect(notifications).toBe(1); + }); + + test("invocation runs the registered callback", () => { + // The store doesn't invoke the callback itself — the App does + // — but verify the wiring lets callers reach the function. + const store = new WizardStore(); + let invoked = 0; + store.setRequestCancel(() => { + invoked += 1; + }); + store.getSnapshot().requestCancel?.(); + expect(invoked).toBe(1); + }); +}); From 30186ad9195429952667c3ad64f7a3cb89f7524c Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:43:15 +0000 Subject: [PATCH 7/9] docs(init): replace stale OpenTUI / OpenTuiUI references with InkUI Sweep the surrounding init/ files (everything except ink-ui.ts and ink-app.tsx, where OpenTUI references intentionally document history) so doc comments accurately describe the current Ink-based implementation. No behavior changes. Touched files: - clack-utils.ts: sidebar comment refs - formatters.ts: header explaining why summary is structured data - git.ts, interactive.ts: paths through WizardUI - types.ts (init): forceLegacyUi rationale - ui/file-tree.ts: tree builder's two consumers - ui/logging-ui.ts: tree-row format docstring - ui/sentry-tips.ts: where tips render - ui/types.ts: WizardSummary, banner, summary, recordFilesReading, setStep doc rewrites - wizard-runner.ts: header + four inline references --- src/lib/init/clack-utils.ts | 4 ++-- src/lib/init/formatters.ts | 8 ++++---- src/lib/init/git.ts | 4 ++-- src/lib/init/interactive.ts | 4 ++-- src/lib/init/types.ts | 4 ++-- src/lib/init/ui/file-tree.ts | 23 ++++++++++++----------- src/lib/init/ui/logging-ui.ts | 4 ++-- src/lib/init/ui/sentry-tips.ts | 2 +- src/lib/init/ui/types.ts | 31 ++++++++++++++++--------------- src/lib/init/wizard-runner.ts | 23 ++++++++++++----------- 10 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index c16297295..52497cfff 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -127,7 +127,7 @@ export const STEP_LABELS: Record = { /** * Canonical execution order of the wizard's workflow steps. * - * Used by the OpenTUI sidebar's progress checklist as the static + * Used by the Ink sidebar's progress checklist as the static * pre-rendered list. The wizard advertises step transitions via * `WizardUI.setStep(...)`; the store back-fills any earlier * `pending` rows as `skipped` when a later step starts (the workflow @@ -154,7 +154,7 @@ export const CANONICAL_STEP_ORDER: readonly string[] = [ /** * Subset of {@link CANONICAL_STEP_ORDER} surfaced in the progress - * checklist. The OpenTUI sidebar is 36 cols wide and shares vertical + * checklist. The Ink sidebar is 36 cols wide and shares vertical * space with the tip card and the files-read panel, so showing all * 12 step rows would push the files panel off-screen on shorter * terminals. diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 8b90d0a37..43cfeec20 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -5,10 +5,10 @@ * the UI implementations render. The previous version assembled * terminal-flavored markdown (color tags, an aligned key/value table, * a tree of changed files) and pushed it through `ui.log.message`. - * That worked for `LoggingUI` (which calls `renderMarkdown`) but - * showed literal markup like `~` and pipe-cells in - * `OpenTuiUI` because TextRenderable can't parse markdown — only - * strip ANSI. + * That worked for `LoggingUI` (which calls `renderMarkdown`) but the + * earlier TUI showed literal markup like `~` and + * pipe-cells because the underlying text primitive couldn't parse + * markdown — only strip ANSI. * * Now `formatResult` calls `ui.summary(structuredData)` and lets each * implementation decide how to lay it out. `formatError` still uses diff --git a/src/lib/init/git.ts b/src/lib/init/git.ts index 15d46b5e4..78e5b0138 100644 --- a/src/lib/init/git.ts +++ b/src/lib/init/git.ts @@ -7,8 +7,8 @@ * Low-level git primitives live in `src/lib/git.ts`. This module * re-exports them for backward compatibility and adds the interactive * `checkGitStatus` orchestrator. All UI I/O is routed through the - * injected `WizardUI` so the same code drives clack, OpenTUI, and the - * non-interactive `LoggingUI` paths. + * injected `WizardUI` so the same code drives `InkUI` (interactive) + * and `LoggingUI` (CI / npm) paths. */ import { diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 29ae0ebe6..e3bdcdf2c 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -6,8 +6,8 @@ * Respects --yes flag for non-interactive mode. * * All UI I/O goes through the injected `WizardUI` so the dispatcher - * works identically against `ClackUI` (interactive), `LoggingUI` (CI), - * and the upcoming OpenTUI implementation. + * works identically against `InkUI` (interactive Bun binary) and + * `LoggingUI` (CI / npm fallback). */ import chalk from "chalk"; diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 6ab708532..1b811203e 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -21,8 +21,8 @@ export type WizardOptions = { org?: string; project?: string; /** - * Force the non-OpenTUI fallback (`LoggingUI`). Mapped from - * `--no-tui`. Acts as an escape hatch when the OpenTUI path + * Force the non-Ink fallback (`LoggingUI`). Mapped from + * `--no-tui`. Acts as an escape hatch when the Ink TUI * misbehaves; in an interactive run this effectively disables * prompts (any prompt path will throw a `LoggingUIPromptError`), * so users hitting this flag should also pass `--yes` or set diff --git a/src/lib/init/ui/file-tree.ts b/src/lib/init/ui/file-tree.ts index e1720e967..84790ee38 100644 --- a/src/lib/init/ui/file-tree.ts +++ b/src/lib/init/ui/file-tree.ts @@ -1,16 +1,17 @@ /** * Changed-files tree builder. * - * Both `OpenTuiUI`'s React `` and `LoggingUI.summary()` - * (plus the post-dispose stderr report) want a nested directory tree - * view of the wizard's changed files — collapses common prefixes and - * makes the actual scope of edits visible at a glance. + * Both `InkUI`'s React `` / `` and + * `LoggingUI.summary()` (plus the post-dispose chalk report) want a + * nested directory tree view of the wizard's changed files — + * collapses common prefixes and makes the actual scope of edits + * visible at a glance. * * The pre-React formatter built this with `colorTag()` markdown tags - * (`+`); the new TUI can't render those because OpenTUI - * strips ANSI from `TextRenderable.content`. Keeping the tree as - * pure data plus a flat render-list lets each renderer attach its - * own colors / box-drawing. + * (`+`); the TUI couldn't render those because the + * text renderer stripped ANSI/markdown. Keeping the tree as pure + * data plus a flat render-list lets each renderer attach its own + * colors / box-drawing. */ export type ChangedFile = { @@ -20,7 +21,7 @@ export type ChangedFile = { /** * One entry in the read-files tree. `status` mirrors the - * `FileReadEntry.status` shape from the wizard store so the OpenTUI + * `FileReadEntry.status` shape from the wizard store so the Ink * `FilesPanel` can render an at-a-glance icon per row. */ export type ReadFile = { @@ -200,8 +201,8 @@ function rowFor( * `action`. * * Insertion order is preserved (no sort) so newly-read files always - * land at the bottom of their parent directory — gives the OpenTUI - * `FilesPanel`'s sticky-bottom scrollbox a stable "tail -f" feel. + * land at the bottom of their parent directory — gives the Ink + * `FilesPanel`'s tail-window viewport a stable "tail -f" feel. */ export function buildReadTree(files: ReadFile[]): FileTreeNode { const root: FileTreeNode = { name: "", children: [] }; diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index 4b4a93704..d3579ff78 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -112,7 +112,7 @@ export class LoggingUI implements WizardUI { this.writeLine(this.stdout, ""); this.writeLine(this.stdout, " Changed files:"); // Render as a directory tree so collapsed common prefixes match - // what the OpenTuiUI panel + post-dispose stderr report show. + // what the InkUI panel + post-dispose summary report show. const tree = buildFileTree(summary.changedFiles); for (const row of flattenTree(tree)) { this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); @@ -225,7 +225,7 @@ function changedFileGlyph(action: string): string { /** * Render a single `FileTreeRow` for the LoggingUI's stdout summary. - * No colors — same shape as the OpenTuiUI / post-dispose tree, but + * No colors — same shape as the InkUI / post-dispose tree, but * box-drawing characters and glyphs ship as plain text so CI logs * stay greppable. */ diff --git a/src/lib/init/ui/sentry-tips.ts b/src/lib/init/ui/sentry-tips.ts index 1a5f29b34..eded84994 100644 --- a/src/lib/init/ui/sentry-tips.ts +++ b/src/lib/init/ui/sentry-tips.ts @@ -2,7 +2,7 @@ * Sentry Tips * * Curated set of short product facts shown rotating in the sidebar of - * `OpenTuiUI` while the wizard runs. Each tip should: + * the Ink sidebar while the wizard runs. Each tip should: * * - fit comfortably in ~36 columns (the sidebar width) when wrapped * - mention a concrete capability the user can apply after onboarding diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 3a5a756e0..87bd941f7 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -121,13 +121,13 @@ export type ConfirmOptions = { * implementation choose its own presentation: * - `LoggingUI` writes a compact two-column key/value listing to * stdout, plus a flat list of changed files. - * - `OpenTuiUI` mounts a colored panel inside the alternate-screen - * layout with proper alignment and per-action glyphs. + * - `InkUI` mounts a colored panel below the log stream with + * proper alignment and per-action glyphs. * * Previously `formatResult` built terminal markdown and called * `ui.log.message(markdown)` — this leaked literal `` tags - * into the OpenTUI panel because OpenTUI's `TextRenderable` has no - * markdown parser, only a `stripAnsi` step. + * because the TUI's text renderer had no markdown parser, only a + * `stripAnsi` step. */ export type WizardSummary = { /** Flat list of ` ); } @@ -881,17 +888,26 @@ function progressStyle(entry: StepEntry): { } /** - * Read-files tree, rendered inside a fixed-height tail-`f`-style - * viewport: the most recent rows are always visible, with a - * `↑ N earlier` indicator at the top when older rows have scrolled - * out of view. + * Read-files tree, rendered inside a fixed-height viewport with a + * visual scrollbar on the right edge and keyboard-driven scroll-back. * - * Why no real scroller? Ink doesn't ship a native scrollbox - * primitive, and a third-party one would mean wiring focus - * management (PgUp/PgDn while a prompt is mounted, etc.) — too - * much complexity for what's effectively a status indicator. - * Tail-window UX matches what the user actually wants: see what - * the wizard is reading right now. + * Auto-follow ("pinned to bottom") mode is the default — newly-read + * files always come into view, like `tail -f`. The user can scroll + * back through history with arrow keys / PgUp / PgDn / Home; pressing + * End or Esc re-pins to the bottom. While unpinned, new file reads + * don't snap the viewport; the user keeps their place in the + * scrollback. + * + * Keyboard: + * - ↑ / ↓ — scroll one row + * - PgUp / PgDn — scroll one viewport + * - Home — jump to oldest entry + * - End / Esc — re-pin to latest (bottom) + * + * The keyboard handler is gated on `!hasActivePrompt` so it doesn't + * fight the active select/multi-select prompt's own `useInput`. When + * a prompt is up, the panel still renders correctly — the user just + * can't scroll until the prompt resolves. * * Visual rules: * - Directories: muted gray box-drawing branches + name with `/`. @@ -899,6 +915,9 @@ function progressStyle(entry: StepEntry): { * normal-color filename. The eye picks these out instantly. * - Analyzed (`status === "analyzed"`): green `✓` glyph, dimmed * filename. Done work recedes; in-flight work pops. + * - Right-edge scrollbar: full-height `│` track with a `█` thumb + * showing the visible window's position relative to total rows. + * Hidden when content fits the viewport. * * Hidden until at least one file has been recorded — the empty box * would just be visual noise during the auth/discover phase. @@ -906,27 +925,137 @@ function progressStyle(entry: StepEntry): { function FilesPanel({ filesRead, maxRows, + hasActivePrompt, }: { filesRead: FileReadEntry[]; maxRows: number; + hasActivePrompt: boolean; }): React.ReactNode { + // Scroll state: `pinnedToBottom` true means viewport tracks the + // newest rows automatically as files arrive. `offset` is the + // number of rows scrolled UP from the bottom — only meaningful + // when not pinned. Both are pure UI state, owned by this + // component (not the wizard store) — they're "what the user is + // looking at", not "what the wizard is doing". + const [pinnedToBottom, setPinnedToBottom] = useState(true); + const [offset, setOffset] = useState(0); + + const tree = buildReadTree(filesRead); + const rows = flattenTree(tree); + const totalRows = rows.length; + + // Header takes 1 row of the vertical budget; reserve it. The + // remainder is the viewport for file rows. + const viewport = Math.max(1, maxRows - 1); + const canScroll = totalRows > viewport; + + // Clamp offset to valid range — protects against shrinking the + // tree (e.g. a re-scan with fewer files) leaving a stale offset + // beyond the new totalRows. + const maxOffset = Math.max(0, totalRows - viewport); + const effectiveOffset = pinnedToBottom ? 0 : Math.min(offset, maxOffset); + + // Visible window: when pinned, the last `viewport` rows. When + // scrolled up by `effectiveOffset`, slide the window up by that + // many rows from the bottom. + const sliceEnd = totalRows - effectiveOffset; + const sliceStart = Math.max(0, sliceEnd - viewport); + const visible = rows.slice(sliceStart, sliceEnd); + + // Track the previous totalRows so we can detect "new files + // arrived while the user was scrolled up" — in that case we keep + // the user's place by bumping `offset` to compensate. Without + // this, new arrivals would shift the user's view by the number + // of new rows. + // + // Also clamps `offset` to the new `maxOffset` when the tree + // shrinks (e.g. a re-scan with fewer files): without the clamp, + // a stale offset beyond the new maxOffset would still display + // correctly via `effectiveOffset`, but the underlying state + // would be wrong and one PgDn would feel inert. + const prevTotalRef = useRef(totalRows); + useEffect(() => { + const prev = prevTotalRef.current; + prevTotalRef.current = totalRows; + if (pinnedToBottom) { + return; + } + const newMax = Math.max(0, totalRows - viewport); + if (totalRows > prev) { + setOffset((current) => Math.min(newMax, current + (totalRows - prev))); + } else if (totalRows < prev) { + setOffset((current) => Math.min(current, newMax)); + } + }, [totalRows, viewport, pinnedToBottom]); + + useInput( + (_input, key) => { + if (!canScroll) { + return; + } + if (key.upArrow) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + 1)); + return; + } + if (key.downArrow) { + setOffset((current) => { + const next = Math.max(0, current - 1); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + return; + } + if (key.pageUp) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + viewport)); + return; + } + if (key.pageDown) { + setOffset((current) => { + const next = Math.max(0, current - viewport); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + return; + } + // Home → jump to oldest (top of scrollback). End / Esc → + // re-pin to latest (bottom). Esc doubles as "stop scrolling" + // because users reach for it instinctively to undo a + // navigation mistake. + if (key.home) { + setPinnedToBottom(false); + setOffset(maxOffset); + return; + } + if (key.end || key.escape) { + setPinnedToBottom(true); + setOffset(0); + } + }, + { isActive: !hasActivePrompt } + ); + + // The store's `filesRead` array is mutated by the bridge — guard + // against rendering an empty panel during the brief window + // before the first `recordFilesReading` call. if (filesRead.length === 0) { return null; } - const tree = buildReadTree(filesRead); - const rows = flattenTree(tree); - // The header takes 1 row of the panel's vertical budget; reserve - // it so the file rows don't get squeezed. - const fileRowBudget = Math.max(1, maxRows - 1); - const truncated = rows.length > fileRowBudget; - // When truncated, the truncation indicator itself takes one row, - // so the actual visible file count is one less. - const visibleFileRows = truncated ? fileRowBudget - 1 : fileRowBudget; - const visible = truncated ? rows.slice(rows.length - visibleFileRows) : rows; - const hidden = rows.length - visible.length; + const analyzedCount = filesRead.filter( (entry) => entry.status === "analyzed" ).length; + // Pad out the visible window so the panel stays a consistent + // height even when totalRows < viewport. Without this, the + // scrollbar column on the right would render shorter than the + // content column, leaving a ragged right edge. + const padding = Math.max(0, viewport - visible.length); + return ( + {pinnedToBottom ? "" : "↑ "} {analyzedCount}/{filesRead.length} - {truncated ? ↑ {hidden} earlier : null} - {visible.map((row, i) => ( - // Tree rows are positionally stable for a given filesRead - // snapshot — `buildReadTree` walks `filesRead` in insertion - // order and never reorders, so the index makes a fine key. - // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows - + + + {visible.map((row, i) => ( + // Tree rows are positionally stable for a given + // filesRead snapshot — `buildReadTree` walks + // `filesRead` in insertion order and never reorders, + // so the index makes a fine key. + // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows + + ))} + {Array.from({ length: padding }, (_, i) => ( + // Empty filler rows — keep the panel a consistent + // height when content underflows the viewport. + // biome-ignore lint/suspicious/noArrayIndexKey: positional filler + + ))} + + {canScroll ? ( + + ) : null} + + + ); +} + +/** + * Vertical scrollbar drawn as a 1-column track of `│` characters + * with a `█` thumb showing the visible window's position. The + * thumb size scales with the ratio of `viewport / totalRows`, + * minimum 1 row so it never disappears entirely. + * + * `offset` is the number of rows scrolled UP from the bottom (0 = + * pinned to bottom). The thumb's vertical position grows as + * `offset` grows, with offset `maxOffset` putting it at the top. + */ +function Scrollbar({ + offset, + totalRows, + viewport, +}: { + offset: number; + totalRows: number; + viewport: number; +}): React.ReactNode { + const maxOffset = Math.max(1, totalRows - viewport); + const thumbSize = Math.max(1, Math.floor((viewport * viewport) / totalRows)); + const trackSpan = Math.max(1, viewport - thumbSize); + // Bottom of viewport corresponds to offset=0 (thumb at bottom). + // Top of viewport corresponds to offset=maxOffset (thumb at top). + // Linearly interpolate between the two. + const thumbStart = Math.round(((maxOffset - offset) / maxOffset) * trackSpan); + const cells = Array.from({ length: viewport }, (_v, i) => { + const inThumb = i >= thumbStart && i < thumbStart + thumbSize; + return inThumb ? "█" : "│"; + }); + return ( + + {cells.map((cell, i) => ( + // Scrollbar cells are positional, stable, and never + // reordered — the index key is correct here. + // biome-ignore lint/suspicious/noArrayIndexKey: positional scrollbar + + {cell} + ))} ); diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 78f38bade..c6159fd57 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -46,6 +46,8 @@ const TIP_HEADER_RE = /Did you know\?/; const PROGRESS_HEADER_RE = /Progress/; const PROGRESS_HEADER_BOUND_RE = /Progress\b/; const DIVIDER_RUNS_RE = /(─+)/g; +const FILES_HEADER_PINNED_RE = /Files analyzed\s+\d+\/\d+/; +const FILES_HEADER_UNPINNED_RE = /Files analyzed\s+↑\s+\d+\/\d+/; const FRAME_SETTLE_MS = 80; @@ -192,6 +194,41 @@ describe("Ink App snapshot", () => { expect(dividerLength).toBeLessThanOrEqual(56); }); + test("FilesPanel renders scrollbar when content exceeds viewport", async () => { + // Drop ~30 file paths into the store so the read-tree exceeds + // the panel's viewport (capped at MAX_FILE_ROWS = 14, minus 1 + // for the header). The visual scrollbar should appear; with + // the panel pinned to the bottom (default state), the `█` + // thumb sits at the bottom of the track. + const fewStore = new WizardStore({ bannerRows: bannerRows() }); + fewStore.recordFilesReading(["package.json", "src/index.ts"]); + const fewFrame = (await renderApp(fewStore, 120)).allOutput(); + const baselineThumbs = (fewFrame.match(/█/g) ?? []).length; + + const manyStore = new WizardStore({ bannerRows: bannerRows() }); + const paths: string[] = []; + for (let i = 0; i < 30; i++) { + paths.push(`src/dir${Math.floor(i / 5)}/file${i}.ts`); + } + manyStore.recordFilesReading(paths); + manyStore.markFilesAnalyzed(paths.slice(0, 18)); + const manyFrame = (await renderApp(manyStore, 120)).allOutput(); + const scrollingThumbs = (manyFrame.match(/█/g) ?? []).length; + + // The banner art uses `█` glyphs too (same codepoint as the + // scrollbar thumb), so we can't assert presence/absence + // against a fixed pattern. But the many-files frame must + // contain MORE `█`s than the few-files frame — those extras + // are the scrollbar thumb cells. + expect(scrollingThumbs).toBeGreaterThan(baselineThumbs); + // Header shows pinned-to-bottom format ("Files analyzed + // N/M", no `↑` prefix). The unpinned format only appears + // after the user scrolls back manually — keyboard scrolling + // can't be exercised from `bun test` without a raw-mode TTY. + expect(manyFrame).toMatch(FILES_HEADER_PINNED_RE); + expect(manyFrame).not.toMatch(FILES_HEADER_UNPINNED_RE); + }); + test("Ctrl+C path uses requestCancel via store, never bare process.exit", () => { // The App's top-level `useInput` reads `requestCancel` from the // store on every keystroke. This test exercises only the store