From 6093faac0a1c414b86e2ff9dbd825bcb9cbe16e5 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:05:49 +0000 Subject: [PATCH 01/20] feat(init): scaffold WizardUI abstraction layer for OpenTUI migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a thin `WizardUI` interface as the single I/O chokepoint for the init wizard, with two implementations: - `ClackUI` — wraps the current `@clack/prompts` calls (default for interactive runs; preserves existing visible behavior). - `LoggingUI` — non-interactive impl for CI / `--yes` / non-TTY contexts. Plain stdout/stderr writes, no spinners, prompts throw `LoggingUIPromptError` so callers must pre-resolve choices. `getUI()` factory selects an implementation based on `SENTRY_INIT_TUI` env var, `--yes`, and stdin/stdout TTY state. The OpenTuiUI branch is reserved for a follow-up PR. This is PR 1 of a staged migration. No call sites change yet — the factory returns `ClackUI` for interactive runs, so observable behavior is unchanged. Subsequent PRs migrate `wizard-runner.ts`, `interactive.ts`, `preflight.ts`, `formatters.ts`, and `git.ts` to call `ui.*`, then add the OpenTUI implementation, then flip the default and remove clack. Adds `@opentui/core` to devDependencies (dev-only; bundled into the compiled binary in PR 3). 34 new unit tests for types, LoggingUI output routing/spinner/prompt rejection/dispose, and factory runtime detection. Existing 191 init tests continue to pass. --- bun.lock | 193 ++++++++++++++++++++++- package.json | 1 + src/lib/init/ui/clack-ui.ts | 150 ++++++++++++++++++ src/lib/init/ui/factory.ts | 110 +++++++++++++ src/lib/init/ui/logging-ui.ts | 184 ++++++++++++++++++++++ src/lib/init/ui/types.ts | 169 ++++++++++++++++++++ test/lib/init/ui/factory.test.ts | 136 ++++++++++++++++ test/lib/init/ui/logging-ui.test.ts | 233 ++++++++++++++++++++++++++++ test/lib/init/ui/types.test.ts | 43 +++++ 9 files changed, 1216 insertions(+), 3 deletions(-) create mode 100644 src/lib/init/ui/clack-ui.ts create mode 100644 src/lib/init/ui/factory.ts create mode 100644 src/lib/init/ui/logging-ui.ts create mode 100644 src/lib/init/ui/types.ts create mode 100644 test/lib/init/ui/factory.test.ts create mode 100644 test/lib/init/ui/logging-ui.test.ts create mode 100644 test/lib/init/ui/types.test.ts diff --git a/bun.lock b/bun.lock index ead0d2419..3f915f486 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@biomejs/biome": "2.3.8", "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", + "@opentui/core": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", @@ -91,6 +92,8 @@ "@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=="], @@ -151,6 +154,62 @@ "@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=="], @@ -173,6 +232,20 @@ "@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=="], + "@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=="], @@ -199,6 +272,8 @@ "@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=="], @@ -239,6 +314,8 @@ "@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=="], @@ -257,6 +334,8 @@ "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=="], @@ -265,14 +344,32 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "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=="], @@ -321,13 +418,15 @@ "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@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -353,10 +452,14 @@ "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=="], @@ -371,6 +474,8 @@ "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=="], + "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=="], @@ -393,6 +498,8 @@ "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=="], @@ -421,8 +528,12 @@ "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=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -441,8 +552,12 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "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=="], + "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=="], @@ -471,7 +586,7 @@ "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -501,6 +616,8 @@ "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=="], @@ -513,6 +630,14 @@ "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=="], @@ -529,16 +654,26 @@ "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=="], @@ -555,6 +690,10 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "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=="], + "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=="], @@ -565,6 +704,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], @@ -589,32 +730,46 @@ "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=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "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=="], + "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=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "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-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=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "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=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "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=="], @@ -629,6 +784,8 @@ "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=="], @@ -639,6 +796,8 @@ "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=="], @@ -649,6 +808,12 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "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=="], @@ -659,6 +824,8 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-from-json-schema": ["zod-from-json-schema@0.5.2", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g=="], @@ -679,6 +846,10 @@ "@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=="], + + "@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=="], + "@peggyjs/from-mem/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -699,20 +870,30 @@ "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=="], + "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=="], + "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=="], @@ -737,8 +918,12 @@ "@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/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -757,6 +942,8 @@ "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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], diff --git a/package.json b/package.json index 8dcf0a878..3fdc30f4f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@biomejs/biome": "2.3.8", "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", + "@opentui/core": "^0.2.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", diff --git a/src/lib/init/ui/clack-ui.ts b/src/lib/init/ui/clack-ui.ts new file mode 100644 index 000000000..716529013 --- /dev/null +++ b/src/lib/init/ui/clack-ui.ts @@ -0,0 +1,150 @@ +/** + * ClackUI — interactive WizardUI implementation backed by `@clack/prompts`. + * + * This is the **default** interactive implementation while the OpenTUI + * port is in progress. Its job is to preserve current visible behavior + * (one-line scrolling layout, clack symbol icons, multiline spinner from + * `createWizardSpinner`) while letting the rest of the wizard code call a + * stable `WizardUI` interface. + * + * The wrapper is intentionally thin — it forwards each call to the same + * clack primitives the wizard already uses. When OpenTuiUI lands in PR3 + * and is flipped to default in PR4, this module is deleted along with + * the `@clack/prompts` dependency. + */ + +import { + type Option as ClackOption, + cancel as clackCancel, + confirm as clackConfirm, + intro as clackIntro, + isCancel as clackIsCancel, + log as clackLog, + multiselect as clackMultiSelect, + outro as clackOutro, + select as clackSelect, +} from "@clack/prompts"; +import { renderMarkdown } from "../../formatters/markdown.js"; +import { createWizardSpinner } from "../spinner.js"; +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOption, + type SelectOptions, + type SpinnerHandle, + type WizardLog, + type WizardUI, +} from "./types.js"; + +/** + * Map a `WizardUI` `SelectOption` to clack's `Option` shape. + * + * Clack's `Option` is a conditional type — `Value extends Primitive` + * — and TypeScript will not distribute the conditional through our own + * generic `T extends string`. Asserting the return type lets the wrapper + * compile while preserving correctness (clack's primitive branch matches + * `string` exactly). + * + * Clack types `hint` as an optional property (`hint?: string`) — meaning + * the key must be either omitted or a `string`. Spreading `option.hint` + * into the object as-is would set the key to `undefined`. The conditional + * spread is kept in one place here. + */ +function toClackOption( + option: SelectOption +): ClackOption { + const base = { value: option.value, label: option.label }; + return ( + option.hint === undefined ? base : { ...base, hint: option.hint } + ) as ClackOption; +} + +/** + * Interactive WizardUI backed by clack. See module doc. + */ +export class ClackUI implements WizardUI { + // ── Lifecycle ───────────────────────────────────────────────────── + + intro(title: string): void { + clackIntro(title); + } + + outro(message: string): void { + clackOutro(message); + } + + cancel(message: string): void { + clackCancel(message); + } + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog = { + info: (message: string) => clackLog.info(message), + warn: (message: string) => clackLog.warn(message), + error: (message: string) => clackLog.error(message), + success: (message: string) => clackLog.success(message), + // `log.message` is the caller's plain markdown block — render it here + // so call sites don't need to import the markdown renderer themselves. + message: (message: string) => clackLog.message(renderMarkdown(message)), + }; + + // ── Spinner ─────────────────────────────────────────────────────── + + spinner(): SpinnerHandle { + return createWizardSpinner(); + } + + // ── Prompts ─────────────────────────────────────────────────────── + + async select( + opts: SelectOptions + ): Promise { + const result = await clackSelect({ + message: opts.message, + options: opts.options.map(toClackOption), + initialValue: opts.initialValue, + }); + if (clackIsCancel(result)) { + return CANCELLED; + } + return result; + } + + async multiselect( + opts: MultiSelectOptions + ): Promise { + const result = await clackMultiSelect({ + message: opts.message, + options: opts.options.map(toClackOption), + initialValues: opts.initialValues, + required: opts.required, + }); + if (clackIsCancel(result)) { + return CANCELLED; + } + return result; + } + + async confirm(opts: ConfirmOptions): Promise { + const result = await clackConfirm({ + message: opts.message, + initialValue: opts.initialValue, + }); + if (clackIsCancel(result)) { + return CANCELLED; + } + return Boolean(result); + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + // Nothing to tear down — clack writes inline and owns no persistent + // renderer state. Spinners returned from `spinner()` self-clean on + // `stop()`. + return Promise.resolve(); + } +} diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts new file mode 100644 index 000000000..8e268da58 --- /dev/null +++ b/src/lib/init/ui/factory.ts @@ -0,0 +1,110 @@ +/** + * WizardUI Factory + * + * Picks the appropriate `WizardUI` implementation based on runtime + * environment and CLI flags. This is the single chokepoint for UI + * selection — every part of the init wizard goes through `getUI()` + * rather than instantiating implementations directly. + * + * Selection priority (highest first): + * + * 1. `SENTRY_INIT_TUI=0` — force `LoggingUI` (debug escape hatch). + * 2. `--yes` flag set, OR stdin is not a TTY, OR stdout is not a TTY — + * force `LoggingUI` (CI / piped input). + * 3. Running on the npm/Node distribution (not the Bun-compiled binary) + * — force `LoggingUI`. OpenTUI is Bun-only and the Node `dist/bin.cjs` + * has no native binding for it. (Note: `OpenTuiUI` itself doesn't land + * until PR3 — until then this branch falls through to `ClackUI` because + * clack works on both runtimes.) + * 4. `SENTRY_INIT_TUI=1` — force the new TUI (once `OpenTuiUI` exists). + * 5. Default — `ClackUI` (today). PR4 flips this to `OpenTuiUI` once the + * full-screen renderer is ready. + * + * `--no-tui` flag handling lives in `src/commands/init.ts` and maps to + * `SENTRY_INIT_TUI=0` before this factory runs. + */ + +import { ClackUI } from "./clack-ui.js"; +import { LoggingUI } from "./logging-ui.js"; +import type { WizardUI } from "./types.js"; + +/** + * Inputs that affect UI selection. Mirrors the relevant subset of + * `WizardOptions` so we don't drag the full type into the factory. + */ +export type UIFactoryOptions = { + /** True when `--yes` (or `--dry-run`, which implies non-interactive) is set. */ + yes: boolean; + /** + * True when the user explicitly opted out of the new TUI via + * `--no-tui` or the wizard is otherwise unable to use it. This lets + * the caller force `ClackUI`/`LoggingUI` without poking env vars. + */ + forceLegacy?: boolean; +}; + +/** + * 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. + * + * Exported for the test suite — production callers should go through + * `getUI()`. + */ +export function isBunRuntime(): boolean { + return ( + typeof globalThis.Bun !== "undefined" && + typeof process.versions.bun === "string" + ); +} + +/** + * Detect whether the current process can run an interactive prompt. + * Both stdin (read keystrokes) and stdout (render the prompt) must be + * TTYs. Piped input or output disqualifies us. + * + * Exported for the test suite. + */ +export function isInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); +} + +/** + * Returns `true` when the `LoggingUI` should be used regardless of any + * other signal — i.e. we're in a non-interactive context. + */ +function shouldUseLogging(opts: UIFactoryOptions): boolean { + if (process.env.SENTRY_INIT_TUI === "0") { + return true; + } + if (opts.yes) { + return true; + } + if (!isInteractiveTerminal()) { + return true; + } + return false; +} + +/** + * Construct the `WizardUI` instance for this run. + * + * Callers should treat the return value as an `AsyncDisposable` and use + * `await using ui = getUI(...)` to guarantee teardown on every exit + * path. Both current implementations have a no-op disposer, but + * `OpenTuiUI` (PR3) will rely on the dispose protocol to restore the + * main screen buffer and stop its render loop. + */ +export function getUI(opts: UIFactoryOptions): WizardUI { + if (shouldUseLogging(opts)) { + return new LoggingUI(); + } + // PR1: interactive runs use ClackUI on both Bun and Node. + // PR3 will replace this branch with `new OpenTuiUI()` when on the + // Bun-compiled binary, falling back to ClackUI on Node — and PR4 + // removes ClackUI altogether. + if (opts.forceLegacy) { + return new ClackUI(); + } + return new ClackUI(); +} diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts new file mode 100644 index 000000000..b545c3374 --- /dev/null +++ b/src/lib/init/ui/logging-ui.ts @@ -0,0 +1,184 @@ +/** + * LoggingUI — non-interactive WizardUI implementation. + * + * Used in CI, with `--yes`, when stdin/stdout is not a TTY, or when the + * user explicitly opts out via `SENTRY_INIT_TUI=0`. Output is plain text + * written directly to stdout/stderr — no ANSI control sequences, no + * spinners, no alternate screen buffer, no prompt rendering. + * + * Prompt methods (`select`, `multiselect`, `confirm`) throw a + * `LoggingUIPromptError`. Callers MUST resolve all interactive choices + * (org, project, team, features, confirmations) up-front through CLI + * flags or `--yes` defaults before invoking any UI prompt method. This + * mirrors PostHog wizard's approach: in CI, the I/O layer cannot fall + * back to stdin reads. + * + * The spinner is a no-op shape — `start`/`message`/`stop` log key + * transitions but do not render an animated indicator. This keeps CI + * logs deterministic and free of carriage returns. + */ + +import { + renderInlineMarkdown, + renderMarkdown, +} from "../../formatters/markdown.js"; +import type { + ConfirmOptions, + MultiSelectOptions, + SelectOptions, + SpinnerExitCode, + SpinnerHandle, + WizardLog, + WizardUI, +} from "./types.js"; + +/** + * Thrown when an interactive prompt is invoked under `LoggingUI`. + * + * The wizard runs in a non-interactive context and the caller did not + * pre-resolve the choice. The message identifies which prompt was + * unexpectedly reached so it can be surfaced as a setup error. + */ +export class LoggingUIPromptError extends Error { + constructor( + promptKind: "select" | "multiselect" | "confirm", + message: string + ) { + super( + `Cannot show ${promptKind} prompt in non-interactive mode: ${message}. ` + + "Pass --yes or provide the value via CLI flags / environment variables." + ); + this.name = "LoggingUIPromptError"; + } +} + +/** + * Optional configuration for `LoggingUI`. Mainly used by tests to redirect + * output away from the real `process.stdout`/`process.stderr`. + */ +export type LoggingUIOptions = { + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; +}; + +const DEFAULT_OPTIONS: Required = { + stdout: process.stdout, + stderr: process.stderr, +}; + +/** + * Plain stdout/stderr WizardUI. See module doc for behavior. + */ +export class LoggingUI implements WizardUI { + private readonly stdout: NodeJS.WritableStream; + private readonly stderr: NodeJS.WritableStream; + + constructor(options: LoggingUIOptions = {}) { + this.stdout = options.stdout ?? DEFAULT_OPTIONS.stdout; + this.stderr = options.stderr ?? DEFAULT_OPTIONS.stderr; + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + intro(title: string): void { + this.writeLine(this.stdout, title); + } + + outro(message: string): void { + this.writeLine(this.stdout, message); + } + + cancel(message: string): void { + this.writeLine(this.stderr, message); + } + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog = { + info: (message: string) => + this.writeLine(this.stdout, `info: ${this.renderInline(message)}`), + warn: (message: string) => + this.writeLine(this.stderr, `warn: ${this.renderInline(message)}`), + error: (message: string) => + this.writeLine(this.stderr, `error: ${this.renderInline(message)}`), + success: (message: string) => + this.writeLine(this.stdout, `ok: ${this.renderInline(message)}`), + message: (message: string) => + this.writeLine(this.stdout, renderMarkdown(message)), + }; + + // ── Spinner (no-op renderer; logs lifecycle transitions) ────────── + + spinner(): SpinnerHandle { + let active = false; + return { + start: (message?: string) => { + active = true; + if (message) { + this.writeLine(this.stdout, `... ${this.renderInline(message)}`); + } + }, + message: (message?: string) => { + if (active && message) { + this.writeLine(this.stdout, `... ${this.renderInline(message)}`); + } + }, + stop: (message?: string, code: SpinnerExitCode = 0) => { + if (!active) { + return; + } + active = false; + if (message) { + const stream = code === 1 ? this.stderr : this.stdout; + const prefix = stopPrefix(code); + this.writeLine(stream, `${prefix} ${this.renderInline(message)}`); + } + }, + }; + } + + // ── Prompts (throw — caller must pre-resolve) ───────────────────── + + select(opts: SelectOptions): Promise { + return Promise.reject(new LoggingUIPromptError("select", opts.message)); + } + + multiselect(opts: MultiSelectOptions): Promise { + return Promise.reject( + new LoggingUIPromptError("multiselect", opts.message) + ); + } + + confirm(opts: ConfirmOptions): Promise { + return Promise.reject(new LoggingUIPromptError("confirm", opts.message)); + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + // No teardown needed — LoggingUI holds no resources beyond the + // injected stream references. + return Promise.resolve(); + } + + // ── Internal helpers ────────────────────────────────────────────── + + private writeLine(stream: NodeJS.WritableStream, text: string): void { + stream.write(`${text}\n`); + } + + private renderInline(message: string): string { + return renderInlineMarkdown(message); + } +} + +function stopPrefix(code: SpinnerExitCode): string { + switch (code) { + case 0: + return "ok:"; + case 1: + return "error:"; + default: + return "warn:"; + } +} diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts new file mode 100644 index 000000000..369d722c6 --- /dev/null +++ b/src/lib/init/ui/types.ts @@ -0,0 +1,169 @@ +/** + * WizardUI Abstraction Layer + * + * Defines the I/O surface used by the init wizard. Concrete implementations + * provide the actual rendering: + * + * - `ClackUI` — current `@clack/prompts`-based interactive UI (default + * while the OpenTUI port is in progress). + * - `OpenTuiUI` — alternate-buffer full-screen UI built on `@opentui/core` + * (Bun-binary only; lands in PR3). + * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, and non-TTY + * environments. Prompts throw — non-interactive callers + * must supply defaults. + * + * Goals: + * 1. Mirror clack's API shape so call sites need minimal changes during + * the migration. + * 2. Use a shared cancellation symbol (`CANCELLED`) so all implementations + * can signal cancellation uniformly. Callers wrap prompt results with + * `abortIfCancelled()` (in `clack-utils.ts`) which re-throws as + * `WizardCancelledError`. + * 3. Stay lean — adopt PostHog wizard's `WizardUI` shape for visual + * look-and-feel only, without the screen router / nanostore / health + * check overlays. + */ + +/** Sentinel symbol returned by prompt methods when the user cancels. */ +export const CANCELLED: unique symbol = Symbol.for( + "sentry-cli:wizard-ui:cancelled" +); +export type Cancelled = typeof CANCELLED; + +/** Type guard for the shared cancellation sentinel. */ +export function isCancelled(value: unknown): value is Cancelled { + return value === CANCELLED; +} + +/** + * Spinner exit status. + * + * - `0` — success (rendered as a green diamond / "Done") + * - `1` — error (rendered as a red square) + * - `2` — warning (rendered as a yellow triangle) + */ +export type SpinnerExitCode = 0 | 1 | 2; + +/** + * Multi-line spinner handle. + * + * Mirrors the existing `WizardSpinner` shape in `src/lib/init/spinner.ts` + * so the long-running suspend/resume loop in `wizard-runner.ts` can swap + * implementations without changing its control flow. + */ +export type SpinnerHandle = { + /** Begin spinning with an optional initial message. */ + start(message?: string): void; + /** Update the message in place while spinning. */ + message(message?: string): void; + /** + * Stop spinning and finalize the block with `message`. The exit `code` + * controls the icon (0 ok, 1 error, 2 warn). + */ + stop(message?: string, code?: SpinnerExitCode): void; +}; + +/** + * Inline log API. Each method renders a single line (or markdown-rendered + * block, in the case of `message`). In `LoggingUI` these go straight to + * stdout/stderr; in TUI implementations they accumulate in a scrollable + * pane. + */ +export type WizardLog = { + /** Informational — neutral icon. */ + info(message: string): void; + /** Warning — yellow icon. */ + warn(message: string): void; + /** Error — red icon. */ + error(message: string): void; + /** Success — green icon. */ + success(message: string): void; + /** Plain markdown-rendered block (no icon). */ + message(message: string): void; +}; + +/** Single option in a `select` / `multiselect` prompt. */ +export type SelectOption = { + value: T; + label: string; + hint?: string; +}; + +/** Args for `select`. */ +export type SelectOptions = { + message: string; + options: SelectOption[]; + initialValue?: T; +}; + +/** Args for `multiselect`. */ +export type MultiSelectOptions = { + message: string; + options: SelectOption[]; + initialValues?: T[]; + required?: boolean; +}; + +/** Args for `confirm`. */ +export type ConfirmOptions = { + message: string; + initialValue?: boolean; +}; + +/** + * The full I/O surface used by the init wizard. + * + * Implementations MUST be safe to dispose via the async dispose protocol — + * `using ui = getUI(...)` semantics in callers tear down renderers, restore + * the main screen buffer, and release any held TTY resources. + */ +export type WizardUI = AsyncDisposable & { + // ── Lifecycle messages ──────────────────────────────────────────── + + /** Display the wizard intro banner / heading. */ + intro(title: string): void; + + /** Display the success outro line. Called on a successful run. */ + outro(message: string): void; + + /** + * Display a cancellation outro line. Called on user-cancelled or aborted + * runs (analogous to clack's `cancel()`). + */ + cancel(message: string): void; + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog; + + // ── Spinner ─────────────────────────────────────────────────────── + + /** + * Create a fresh spinner handle. Implementations may share a single + * underlying spinner widget across calls — callers should not assume + * each `spinner()` returns an independent renderable. + */ + spinner(): SpinnerHandle; + + // ── Prompts ─────────────────────────────────────────────────────── + + /** + * Single-choice select. Returns the selected value, or {@link CANCELLED} + * if the user aborted (Ctrl+C / Escape). + */ + select(opts: SelectOptions): Promise; + + /** + * Multi-choice select. Returns the selected values, or {@link CANCELLED} + * if the user aborted. + */ + multiselect( + opts: MultiSelectOptions + ): Promise; + + /** + * Yes/no confirm. Returns the boolean answer, or {@link CANCELLED} if + * the user aborted. + */ + confirm(opts: ConfirmOptions): Promise; +}; diff --git a/test/lib/init/ui/factory.test.ts b/test/lib/init/ui/factory.test.ts new file mode 100644 index 000000000..f47d51ec4 --- /dev/null +++ b/test/lib/init/ui/factory.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for getUI() — verifies the runtime-detection rules pick the + * right WizardUI implementation. + * + * The factory's selection logic depends on three signals: + * - `SENTRY_INIT_TUI` env var + * - `--yes` flag (passed in via opts) + * - stdin/stdout TTY state + * + * We patch the env and `process.stdin.isTTY` / `process.stdout.isTTY` + * around each test so the assertions are deterministic. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { ClackUI } from "../../../../src/lib/init/ui/clack-ui.js"; +import { + getUI, + isInteractiveTerminal, +} from "../../../../src/lib/init/ui/factory.js"; +import { LoggingUI } from "../../../../src/lib/init/ui/logging-ui.js"; + +/** + * Snapshot of the process state we mutate per test. Restored in + * afterEach so the test runner's own TTY/env is left untouched. + */ +type TerminalSnapshot = { + stdinTTY: boolean | undefined; + stdoutTTY: boolean | undefined; + envValue: string | undefined; +}; + +const ENV_KEY = "SENTRY_INIT_TUI"; + +function snapshot(): TerminalSnapshot { + return { + stdinTTY: process.stdin.isTTY, + stdoutTTY: process.stdout.isTTY, + envValue: process.env[ENV_KEY], + }; +} + +function restore(snap: TerminalSnapshot): void { + // Direct property writes match how Node exposes these flags. + (process.stdin as { isTTY: boolean | undefined }).isTTY = snap.stdinTTY; + (process.stdout as { isTTY: boolean | undefined }).isTTY = snap.stdoutTTY; + if (snap.envValue === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = snap.envValue; + } +} + +function setInteractive(interactive: boolean): void { + (process.stdin as { isTTY: boolean }).isTTY = interactive; + (process.stdout as { isTTY: boolean }).isTTY = interactive; +} + +let saved: TerminalSnapshot; + +beforeEach(() => { + saved = snapshot(); + delete process.env[ENV_KEY]; +}); + +afterEach(() => { + restore(saved); +}); + +describe("isInteractiveTerminal", () => { + test("returns true when both stdin and stdout are TTYs", () => { + setInteractive(true); + expect(isInteractiveTerminal()).toBe(true); + }); + + test("returns false when stdin is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = true; + expect(isInteractiveTerminal()).toBe(false); + }); + + test("returns false when stdout is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = false; + expect(isInteractiveTerminal()).toBe(false); + }); +}); + +describe("getUI selection", () => { + test("returns LoggingUI when --yes is set, even on a TTY", () => { + setInteractive(true); + const ui = getUI({ yes: true }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when stdin is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = getUI({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when stdout is not a TTY", () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = false; + const ui = getUI({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns LoggingUI when SENTRY_INIT_TUI=0 even on interactive TTY", () => { + setInteractive(true); + process.env[ENV_KEY] = "0"; + const ui = getUI({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("returns ClackUI on interactive TTY without --yes", () => { + setInteractive(true); + const ui = getUI({ yes: false }); + expect(ui).toBeInstanceOf(ClackUI); + }); + + test("returns ClackUI when forceLegacy is set on interactive TTY", () => { + setInteractive(true); + const ui = getUI({ yes: false, forceLegacy: true }); + expect(ui).toBeInstanceOf(ClackUI); + }); + + test("forceLegacy does not override the non-interactive guard", () => { + // Even with forceLegacy, a non-TTY context must use LoggingUI — + // ClackUI would attempt to read stdin and hang. + (process.stdin as { isTTY: boolean }).isTTY = false; + (process.stdout as { isTTY: boolean }).isTTY = false; + const ui = getUI({ yes: false, forceLegacy: true }); + expect(ui).toBeInstanceOf(LoggingUI); + }); +}); diff --git a/test/lib/init/ui/logging-ui.test.ts b/test/lib/init/ui/logging-ui.test.ts new file mode 100644 index 000000000..86c01fde6 --- /dev/null +++ b/test/lib/init/ui/logging-ui.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for LoggingUI — verifies non-interactive output is emitted to the + * appropriate stream (stdout vs stderr), spinners are line-stable, and + * prompt methods throw `LoggingUIPromptError`. + * + * Output is captured via injected `Writable` streams so we don't write to + * the real terminal during tests. + */ + +import { describe, expect, test } from "bun:test"; +import { Writable } from "node:stream"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + LoggingUI, + LoggingUIPromptError, +} from "../../../../src/lib/init/ui/logging-ui.js"; + +/** + * Test helper: constructs a LoggingUI with two in-memory sinks and + * exposes them as ANSI-stripped string snapshots. + * + * Stripping ANSI keeps assertions terminal-agnostic — `LoggingUI` runs + * markdown through `renderInlineMarkdown`, which can emit color codes + * depending on the parent process's TTY/`FORCE_COLOR` state. + */ +function createUI(): { + ui: LoggingUI; + stdout: () => string; + stderr: () => string; +} { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const stdout = new Writable({ + write(chunk, _encoding, callback): void { + stdoutChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + const stderr = new Writable({ + write(chunk, _encoding, callback): void { + stderrChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + const ui = new LoggingUI({ stdout, stderr }); + return { + ui, + stdout: () => stripAnsi(Buffer.concat(stdoutChunks).toString("utf-8")), + stderr: () => stripAnsi(Buffer.concat(stderrChunks).toString("utf-8")), + }; +} + +describe("LoggingUI lifecycle messages", () => { + test("intro writes to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.intro("Starting wizard"); + expect(stdout()).toBe("Starting wizard\n"); + expect(stderr()).toBe(""); + }); + + test("outro writes to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.outro("All done"); + expect(stdout()).toBe("All done\n"); + expect(stderr()).toBe(""); + }); + + test("cancel writes to stderr", () => { + const { ui, stdout, stderr } = createUI(); + ui.cancel("Aborted by user"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("Aborted by user\n"); + }); +}); + +describe("LoggingUI log API", () => { + test("info writes to stdout with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.info("hello"); + expect(stdout()).toBe("info: hello\n"); + expect(stderr()).toBe(""); + }); + + test("success writes to stdout with prefix", () => { + const { ui, stdout } = createUI(); + ui.log.success("done"); + expect(stdout()).toBe("ok: done\n"); + }); + + test("warn writes to stderr with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.warn("careful"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("warn: careful\n"); + }); + + test("error writes to stderr with prefix", () => { + const { ui, stdout, stderr } = createUI(); + ui.log.error("nope"); + expect(stdout()).toBe(""); + expect(stderr()).toBe("error: nope\n"); + }); + + test("message renders markdown to stdout", () => { + const { ui, stdout } = createUI(); + ui.log.message("# Heading\n\nbody"); + const out = stdout(); + // We don't assert exact ANSI output — just confirm content survived. + expect(out).toContain("Heading"); + expect(out).toContain("body"); + expect(out.endsWith("\n")).toBe(true); + }); +}); + +describe("LoggingUI spinner", () => { + test("emits a single line per lifecycle event", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.message("Still working"); + spinner.stop("Done", 0); + const lines = stdout().split("\n").filter(Boolean); + expect(lines).toEqual(["... Working", "... Still working", "ok: Done"]); + }); + + test("error stop routes to stderr with error prefix", () => { + const { ui, stdout, stderr } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Boom", 1); + expect(stdout()).toBe("... Working\n"); + expect(stderr()).toBe("error: Boom\n"); + }); + + test("warn stop uses warn prefix", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Heads up", 2); + const lines = stdout().split("\n").filter(Boolean); + expect(lines.at(-1)).toBe("warn: Heads up"); + }); + + test("stop without start is a no-op", () => { + const { ui, stdout, stderr } = createUI(); + ui.spinner().stop("nothing", 0); + expect(stdout()).toBe(""); + expect(stderr()).toBe(""); + }); + + test("message after stop does not emit", () => { + const { ui, stdout } = createUI(); + const spinner = ui.spinner(); + spinner.start("Working"); + spinner.stop("Done"); + spinner.message("ignored"); + const lines = stdout().split("\n").filter(Boolean); + expect(lines).toEqual(["... Working", "ok: Done"]); + }); +}); + +describe("LoggingUI prompts throw", () => { + test("select rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect( + ui.select({ + message: "Pick one", + options: [{ value: "a", label: "A" }], + }) + ).rejects.toBeInstanceOf(LoggingUIPromptError); + }); + + test("multiselect rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect( + ui.multiselect({ + message: "Pick many", + options: [{ value: "a", label: "A" }], + }) + ).rejects.toBeInstanceOf(LoggingUIPromptError); + }); + + test("confirm rejects with LoggingUIPromptError", async () => { + const { ui } = createUI(); + expect(ui.confirm({ message: "Sure?" })).rejects.toBeInstanceOf( + LoggingUIPromptError + ); + }); + + test("error message identifies the prompt kind and message", async () => { + const { ui } = createUI(); + let caught: unknown; + try { + await ui.select({ + message: "Pick org", + options: [{ value: "a", label: "A" }], + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(LoggingUIPromptError); + const message = (caught as Error).message; + expect(message).toContain("select"); + expect(message).toContain("Pick org"); + expect(message).toContain("--yes"); + }); +}); + +describe("LoggingUI disposal", () => { + test("[Symbol.asyncDispose] resolves without writing", async () => { + const { ui, stdout, stderr } = createUI(); + await ui[Symbol.asyncDispose](); + expect(stdout()).toBe(""); + expect(stderr()).toBe(""); + }); + + test("works with await using", async () => { + const stdoutChunks: Buffer[] = []; + const stdout = new Writable({ + write(chunk, _encoding, callback): void { + stdoutChunks.push(Buffer.from(chunk)); + callback(); + }, + }); + { + await using ui = new LoggingUI({ stdout, stderr: stdout }); + ui.intro("hi"); + } + expect(stripAnsi(Buffer.concat(stdoutChunks).toString("utf-8"))).toBe( + "hi\n" + ); + }); +}); diff --git a/test/lib/init/ui/types.test.ts b/test/lib/init/ui/types.test.ts new file mode 100644 index 000000000..f200937b4 --- /dev/null +++ b/test/lib/init/ui/types.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the WizardUI shared cancellation sentinel and type guard. + * + * The interface itself has no runtime surface — these tests cover only + * the helpers in `types.ts` that ship with it. + */ + +import { describe, expect, test } from "bun:test"; +import { CANCELLED, isCancelled } from "../../../../src/lib/init/ui/types.js"; + +describe("CANCELLED sentinel", () => { + test("is a symbol", () => { + expect(typeof CANCELLED).toBe("symbol"); + }); + + test("is registered globally so cross-bundle equality holds", () => { + // Symbol.for ensures any caller that imports `CANCELLED` from this + // module path gets the exact same symbol — important when the wizard + // straddles bundled and source contexts (compiled binary vs tests). + expect(CANCELLED).toBe(Symbol.for("sentry-cli:wizard-ui:cancelled")); + }); +}); + +describe("isCancelled", () => { + test("returns true for the sentinel", () => { + expect(isCancelled(CANCELLED)).toBe(true); + }); + + test("returns false for arbitrary values", () => { + expect(isCancelled(undefined)).toBe(false); + expect(isCancelled(null)).toBe(false); + expect(isCancelled(false)).toBe(false); + expect(isCancelled(0)).toBe(false); + expect(isCancelled("")).toBe(false); + expect(isCancelled("CANCELLED")).toBe(false); + expect(isCancelled({})).toBe(false); + }); + + test("returns false for unrelated symbols", () => { + expect(isCancelled(Symbol("cancelled"))).toBe(false); + expect(isCancelled(Symbol.for("other"))).toBe(false); + }); +}); From d66470920e1d0f046374cfd27ecefe5436fe8058 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:26:04 +0000 Subject: [PATCH 02/20] feat(init): migrate wizard call sites to WizardUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces direct `@clack/prompts` calls with the `WizardUI` interface across the init wizard. Functional behavior is unchanged because the factory still returns `ClackUI` for interactive runs (which forwards to clack under the hood) and `LoggingUI` for non-interactive contexts. Migrated modules: - `wizard-runner.ts` — constructs a single `WizardUI` via `getUI()`, passes it through `preamble()`, `resolveInitContext()`, and `handleSuspendedStep()`. Uses `ui.spinner()`, `ui.log`, `ui.intro`, `ui.cancel`, and `ui.confirm` instead of clack primitives. Cleans up via `await using ui = getUI(...)`. - `interactive.ts` — accepts `ui` as a third arg; delegates select / multiselect / confirm to it. - `preflight.ts` — accepts `ui` and routes org / project / team selection through it. - `formatters.ts` — `formatResult` and `formatError` accept `ui` and call `ui.log.message`, `ui.outro`, `ui.cancel`. The `log.message` contract changed: implementations now own markdown rendering, so callers pass raw markdown rather than pre-rendered ANSI. - `git.ts` — `checkGitStatus` accepts `ui` in its options bag. - `clack-utils.ts` — `abortIfCancelled()` recognises both the unified `CANCELLED` sentinel from `ui/types.ts` and clack's legacy cancel symbol (the latter is kept for safety during the migration window). Return type changed to `Exclude` so callers passing a union with a symbol member get the narrowed non-symbol type back. Tests now construct a `MockUI` (new helper at `test/lib/init/ui/mock-ui.ts`) that records every UI call and replays canned prompt responses, replacing the previous `spyOn(clack, ...)` mocks. `wizard-runner.test.ts` replaces `spyOn(initSpinner, "createWizardSpinner")` with `spyOn(uiFactory, "getUI")` returning a MockUI whose `spinner()` is the existing test spinner mock. 345/345 init/types/commands tests pass; typecheck clean; ultracite clean; `check:deps` clean. PR 3 implements `OpenTuiUI`; PR 4 flips the default and removes ClackUI. --- src/lib/init/clack-utils.ts | 40 +++-- src/lib/init/formatters.ts | 39 +++-- src/lib/init/git.ts | 28 ++-- src/lib/init/interactive.ts | 54 ++++--- src/lib/init/preflight.ts | 80 +++++---- src/lib/init/wizard-runner.ts | 87 ++++++---- test/lib/init/formatters.test.ts | 242 +++++++++++++++------------- test/lib/init/git.test.ts | 122 +++++++------- test/lib/init/interactive.test.ts | 178 +++++++++----------- test/lib/init/preflight.test.ts | 93 +++++------ test/lib/init/ui/mock-ui.ts | 152 +++++++++++++++++ test/lib/init/wizard-runner.test.ts | 100 +++++++----- 12 files changed, 724 insertions(+), 491 deletions(-) create mode 100644 test/lib/init/ui/mock-ui.ts diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index 4a135a971..37547e4d4 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -1,12 +1,20 @@ /** - * Clack Utilities + * Wizard Utilities * - * Shared helpers for the clack-based init wizard UI. + * Shared cancellation/error helpers and feature labels for the init + * wizard. Originally a clack-specific utility module — the name is + * preserved for now to keep diffs minimal across PRs while the UI + * layer is migrated. PR 4 renames this file to `wizard-utils.ts` after + * the clack dependency is removed. + * + * `abortIfCancelled()` recognises **both** the new `WizardUI` + * cancellation sentinel and clack's legacy cancel symbol — the latter + * because `ClackUI` returns the unified sentinel but downstream callers + * may still receive raw clack symbols during the migration window. */ -import { terminalLink } from "../formatters/colors.js"; -import { cancel, isCancel } from "./clack-plain.js"; -import { SENTRY_DOCS_URL } from "./constants.js"; +import { isCancel as clackIsCancel } from "./clack-plain.js"; +import { isCancelled } from "./ui/types.js"; export class WizardCancelledError extends Error { constructor() { @@ -15,14 +23,24 @@ export class WizardCancelledError extends Error { } } -export function abortIfCancelled(value: T | symbol): T { - if (isCancel(value)) { - cancel( - `Setup cancelled. You can visit ${terminalLink(SENTRY_DOCS_URL)} to set up manually.` - ); +/** + * Coerce a possibly-cancelled prompt result into the resolved value, or + * throw `WizardCancelledError` on cancellation. + * + * Recognises the unified `CANCELLED` sentinel from `ui/types.ts`. Also + * recognises clack's legacy cancel symbol so callers that still touch + * clack directly continue to work during PR 2. + * + * The return type uses `Exclude` so callers passing a union + * that includes a symbol member (e.g. `string[] | typeof CANCELLED`) + * receive the narrowed non-symbol type back — TypeScript otherwise + * widens `T` to the full union and refuses to call array methods on it. + */ +export function abortIfCancelled(value: T): Exclude { + if (isCancelled(value) || clackIsCancel(value)) { throw new WizardCancelledError(); } - return value as T; + return value as Exclude; } const FEATURE_INFO: Record = { diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index cdf3a590b..0505aaf14 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -1,12 +1,16 @@ /** * Output Formatters * - * Format wizard results and errors for terminal display using clack. + * Format wizard results and errors for terminal display. + * + * All UI I/O goes through the injected `WizardUI` — the human-readable + * markdown is built here, then handed off as a single string per call. + * `WizardUI.log.message` is responsible for rendering the markdown + * (terminal-styled in `ClackUI`/`OpenTuiUI`, plain text in `LoggingUI`). */ import { terminalLink } from "../formatters/colors.js"; -import { colorTag, mdKvTable, renderMarkdown } from "../formatters/markdown.js"; -import { cancel, log, outro } from "./clack-plain.js"; +import { colorTag, mdKvTable } from "../formatters/markdown.js"; import { featureLabel } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, @@ -14,6 +18,7 @@ import { EXIT_VERIFICATION_FAILED, } from "./constants.js"; import type { WizardOutput, WorkflowRunResult } from "./types.js"; +import type { WizardUI } from "./ui/types.js"; type ChangedFile = NonNullable[number]; @@ -160,55 +165,57 @@ function buildSummary(output: WizardOutput): string { return sections.join("\n\n"); } -export function formatResult(result: WorkflowRunResult): void { +export function formatResult(result: WorkflowRunResult, ui: WizardUI): void { const output: WizardOutput = result.result ?? {}; const md = buildSummary(output); if (md.length > 0) { - log.message(renderMarkdown(md)); + ui.log.message(md); } if (output.warnings?.length) { for (const w of output.warnings) { - log.warn(w); + ui.log.warn(w); } } - log.info("Please review the changes above before committing."); - log.info( + ui.log.info("Please review the changes above before committing."); + ui.log.info( "You're one of the first to try the new setup wizard! Run `sentry cli feedback` to let us know how it went." ); - outro("Sentry SDK installed successfully!"); + ui.outro("Sentry SDK installed successfully!"); } -export function formatError(result: WorkflowRunResult): void { +export function formatError(result: WorkflowRunResult, ui: WizardUI): void { const inner = result.result; const message = result.error ?? inner?.message ?? "Wizard failed with an unknown error"; const exitCode = inner?.exitCode ?? 1; - log.error(String(message)); + ui.log.error(String(message)); if (exitCode === EXIT_PLATFORM_NOT_DETECTED) { - log.warn( + ui.log.warn( "Hint: Could not detect your project's platform. Check that the directory contains a valid project." ); } else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) { const commands = inner?.commands; if (commands?.length) { - log.warn( + ui.log.warn( `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}` ); } } else if (exitCode === EXIT_VERIFICATION_FAILED) { - log.warn("Hint: Fix the verification issues and run 'sentry init' again."); + ui.log.warn( + "Hint: Fix the verification issues and run 'sentry init' again." + ); } const docsUrl = inner?.docsUrl; if (docsUrl) { - log.info(`Docs: ${terminalLink(docsUrl)}`); + ui.log.info(`Docs: ${terminalLink(docsUrl)}`); } - cancel("Setup failed"); + ui.cancel("Setup failed"); } diff --git a/src/lib/init/git.ts b/src/lib/init/git.ts index a45fafb34..15d46b5e4 100644 --- a/src/lib/init/git.ts +++ b/src/lib/init/git.ts @@ -6,14 +6,17 @@ * * Low-level git primitives live in `src/lib/git.ts`. This module * re-exports them for backward compatibility and adds the interactive - * `checkGitStatus` orchestrator (coupled to `@clack/prompts` UI). + * `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. */ import { getUncommittedFiles, isInsideGitWorkTree as isInsideWorkTree, } from "../git.js"; -import { confirm, isCancel, log } from "./clack-plain.js"; +import type { WizardUI } from "./ui/types.js"; +import { isCancelled } from "./ui/types.js"; /** Maximum number of uncommitted files to display before truncating. */ const MAX_DISPLAYED_FILES = 5; @@ -43,24 +46,25 @@ export function getUncommittedOrUntrackedFiles(opts: { export async function checkGitStatus(opts: { cwd: string; yes: boolean; + ui: WizardUI; }): Promise { - const { cwd, yes } = opts; + const { cwd, yes, ui } = opts; if (!isInsideGitWorkTree({ cwd })) { if (yes) { - log.warn( + ui.log.warn( "You are not inside a git repository. Unable to revert changes if something goes wrong." ); return true; } - const proceed = await confirm({ + const proceed = await ui.confirm({ message: "You are not inside a git repository. Unable to revert changes if something goes wrong. Continue?", }); - if (isCancel(proceed)) { + if (isCancelled(proceed)) { return false; } - return !!proceed; + return Boolean(proceed); } const uncommitted = getUncommittedOrUntrackedFiles({ cwd }); @@ -72,19 +76,19 @@ export async function checkGitStatus(opts: { } const fileList = displayed.join("\n"); if (yes) { - log.warn( + ui.log.warn( `You have uncommitted or untracked files:\n${fileList}\nProceeding anyway (--yes).` ); return true; } - log.warn(`You have uncommitted or untracked files:\n${fileList}`); - const proceed = await confirm({ + ui.log.warn(`You have uncommitted or untracked files:\n${fileList}`); + const proceed = await ui.confirm({ message: "Continue with uncommitted changes?", }); - if (isCancel(proceed)) { + if (isCancelled(proceed)) { return false; } - return !!proceed; + return Boolean(proceed); } return true; diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index d5ac055e5..99617c07a 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -4,6 +4,10 @@ * Handles interactive prompts from the remote workflow. * Supports select, multi-select, and confirm prompts. * 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. */ import chalk from "chalk"; @@ -22,18 +26,20 @@ import type { MultiSelectPayload, SelectPayload, } from "./types.js"; +import type { WizardUI } from "./ui/types.js"; export async function handleInteractive( payload: InteractivePayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { switch (payload.kind) { case "select": - return await handleSelect(payload, options); + return await handleSelect(payload, options, ui); case "multi-select": - return await handleMultiSelect(payload, options); + return await handleMultiSelect(payload, options, ui); case "confirm": - return await handleConfirm(payload, options); + return await handleConfirm(payload, options, ui); default: return { cancelled: true }; } @@ -41,7 +47,8 @@ export async function handleInteractive( async function handleSelect( payload: SelectPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { const apps = payload.apps ?? []; const items = payload.options ?? apps.map((a) => a.name); @@ -52,23 +59,23 @@ async function handleSelect( if (options.yes) { if (items.length === 1) { - log.info(`Auto-selected: ${items[0]}`); + ui.log.info(`Auto-selected: ${items[0]}`); return { selectedApp: items[0] }; } - log.error( + ui.log.error( `--yes requires exactly one option for selection, but found ${items.length}. Run interactively to choose.` ); return { cancelled: true }; } - const selected = await select({ + const selected = await ui.select({ message: payload.prompt, options: items.map((item, i) => { const app = apps[i]; return { value: item, label: item, - hint: app?.framework ?? undefined, + ...(app?.framework ? { hint: app.framework } : {}), }; }), }); @@ -78,7 +85,8 @@ async function handleSelect( async function handleMultiSelect( payload: MultiSelectPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { const available = payload.availableFeatures ?? payload.options ?? []; @@ -89,7 +97,7 @@ async function handleMultiSelect( const hasRequired = available.includes(REQUIRED_FEATURE); if (options.yes) { - log.info( + ui.log.info( `Auto-selected all features: ${available.map(featureLabel).join(", ")}` ); return { features: available }; @@ -101,7 +109,7 @@ async function handleMultiSelect( if (optional.length === 0) { if (hasRequired) { - log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); + ui.log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); } return { features: hasRequired ? [REQUIRED_FEATURE] : [] }; } @@ -116,13 +124,16 @@ async function handleMultiSelect( } hints.push(`${bar} ${chalk.dim("space=toggle, a=all, enter=confirm")}`); - const selected = await multiselect({ + const selected = await ui.multiselect({ message: `${payload.prompt}\n${hints.join("\n")}`, - options: optional.map((feature) => ({ - value: feature, - label: featureLabel(feature), - hint: featureHint(feature), - })), + options: optional.map((feature) => { + const hint = featureHint(feature); + return { + value: feature, + label: featureLabel(feature), + ...(hint ? { hint } : {}), + }; + }), initialValues: optional.filter((f) => f === "performanceMonitoring"), required: false, }); @@ -137,14 +148,15 @@ async function handleMultiSelect( async function handleConfirm( payload: ConfirmPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { if (options.yes) { - log.info("Auto-confirmed: continuing"); + ui.log.info("Auto-confirmed: continuing"); return { action: "continue" }; } - const confirmed = await confirm({ + const confirmed = await ui.confirm({ message: payload.prompt, initialValue: true, }); diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index c6b4a08f1..df6e2109b 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -13,6 +13,7 @@ import type { ResolvedInitContext, WizardOptions, } from "./types.js"; +import { isCancelled, type WizardUI } from "./ui/types.js"; const NUMERIC_ORG_ID_RE = /^\d+$/; @@ -37,41 +38,48 @@ type ProjectSelection = Pick< * Resolve org, project, team, and auth state before the init workflow starts. */ export async function resolveInitContext( - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { - return await withPreflightHandling(async () => { - const seed = await resolveInitContextSeed(initial); + return await withPreflightHandling(ui, async () => { + const seed = await resolveInitContextSeed(initial, ui); if (!seed) { return null; } - const org = await ensureOrg(seed.org, initial); - const projectSelection = await resolveProjectSelection(org, initial, seed); + const org = await ensureOrg(seed.org, initial, ui); + const projectSelection = await resolveProjectSelection( + org, + initial, + seed, + ui + ); if (!projectSelection) { return null; } - const team = await resolveTeam(org, initial); + const team = await resolveTeam(org, initial, ui); return buildResolvedInitContext(initial, org, team, projectSelection); }); } async function withPreflightHandling( + ui: WizardUI, action: () => Promise ): Promise { try { return await action(); } catch (error) { if (error instanceof WizardCancelledError) { - cancel("Setup cancelled."); + ui.cancel("Setup cancelled."); process.exitCode = 0; return null; } const message = error instanceof Error ? error.message : String(error); - log.error(message); - cancel("Setup failed."); + ui.log.error(message); + ui.cancel("Setup failed."); throw error instanceof WizardError ? error : new WizardError(message); } } @@ -96,9 +104,10 @@ function buildResolvedInitContext( } async function resolveInitContextSeed( - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { - const detected = await resolveDetectedProject(initial); + const detected = await resolveDetectedProject(initial, ui); if (detected?.shouldAbort) { return null; } @@ -112,13 +121,14 @@ async function resolveInitContextSeed( async function ensureOrg( org: string | undefined, - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { if (org) { return org; } - const orgResult = await resolveOrgSlug(initial.directory, initial.yes); + const orgResult = await resolveOrgSlug(initial.directory, initial.yes, ui); if (typeof orgResult === "string") { return orgResult; } @@ -129,7 +139,8 @@ async function ensureOrg( async function resolveProjectSelection( org: string, initial: WizardOptions, - seed: InitContextSeed + seed: InitContextSeed, + ui: WizardUI ): Promise { if (!seed.project) { return { @@ -144,6 +155,7 @@ async function resolveProjectSelection( existingProject: seed.existingProject, yes: initial.yes, promptOnExisting: Boolean(initial.project && !initial.org), + ui, }); if (resolved.shouldAbort) { return null; @@ -168,7 +180,10 @@ function mergeProjectSelection( }; } -async function resolveDetectedProject(initial: WizardOptions): Promise<{ +async function resolveDetectedProject( + initial: WizardOptions, + ui: WizardUI +): Promise<{ org?: string; project?: string; existingProject?: ExistingProjectData; @@ -201,21 +216,21 @@ async function resolveDetectedProject(initial: WizardOptions): Promise<{ }; } - const choice = await select({ + const choice = await ui.select<"existing" | "create">({ message: "Found an existing Sentry project in this codebase.", options: [ { - value: "existing" as const, + value: "existing", label: `Use existing project (${detectedProject.orgSlug}/${detectedProject.projectSlug})`, hint: "Sentry is already configured here", }, { - value: "create" as const, + value: "create", label: "Create a new Sentry project", }, ], }); - if (isCancel(choice)) { + if (isCancelled(choice)) { throw new WizardCancelledError(); } if (choice === "existing") { @@ -235,6 +250,7 @@ async function resolveExistingProjectChoice(opts: { existingProject?: ExistingProjectData; yes: boolean; promptOnExisting: boolean; + ui: WizardUI; }): Promise { const slug = slugify(opts.project); if (!slug) { @@ -258,22 +274,22 @@ async function resolveExistingProjectChoice(opts: { }; } - const choice = await select({ + const choice = await opts.ui.select<"existing" | "create">({ message: `Found existing project '${slug}' in ${opts.org}.`, options: [ { - value: "existing" as const, + value: "existing", label: `Use existing (${opts.org}/${slug})`, hint: "Already configured", }, { - value: "create" as const, + value: "create", label: "Create a new project", hint: "Wizard will detect the project name from your codebase", }, ], }); - if (isCancel(choice)) { + if (isCancelled(choice)) { throw new WizardCancelledError(); } if (choice === "create") { @@ -288,7 +304,8 @@ async function resolveExistingProjectChoice(opts: { async function resolveTeam( org: string, - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { try { const result = await resolveOrCreateTeam(org, { @@ -297,17 +314,17 @@ async function resolveTeam( dryRun: initial.dryRun, deferAutoCreateOnEmptyOrg: true, onAmbiguous: initial.yes - ? async (candidates) => (candidates[0] as SentryTeam).slug + ? (candidates) => Promise.resolve((candidates[0] as SentryTeam).slug) : async (candidates) => { - const selected = await select({ + const selected = await ui.select({ message: "Which team should own this project?", options: candidates.map((team) => ({ value: team.slug, label: team.slug, - hint: team.name !== team.slug ? team.name : undefined, + ...(team.name !== team.slug ? { hint: team.name } : {}), })), }); - if (isCancel(selected)) { + if (isCancelled(selected)) { throw new WizardCancelledError(); } return selected; @@ -326,7 +343,8 @@ async function resolveTeam( async function resolveOrgSlug( cwd: string, - yes: boolean + yes: boolean, + ui: WizardUI ): Promise { const resolved = await resolveOrgPrefetched(cwd); if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { @@ -352,7 +370,7 @@ async function resolveOrgSlug( }; } - const selected = await select({ + const selected = await ui.select({ message: "Which organization should the project be created in?", options: orgs.map((org) => ({ value: org.slug, @@ -360,7 +378,7 @@ async function resolveOrgSlug( hint: org.slug, })), }); - if (isCancel(selected)) { + if (isCancelled(selected)) { throw new WizardCancelledError(); } return selected; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 2a1c31c90..3c3ccdbe9 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -4,6 +4,11 @@ * Main suspend/resume loop that drives the remote Mastra workflow. * Each iteration: check status → if suspended, perform tool or * interactive prompt → resume with result → repeat. + * + * All UI I/O — banners, spinners, logs, prompts, outro — flows through + * a single `WizardUI` instance constructed by `getUI()`. The runner + * itself is implementation-agnostic: it works the same against + * `ClackUI`, `LoggingUI`, and the upcoming OpenTUI implementation. */ import { randomBytes } from "node:crypto"; @@ -37,7 +42,6 @@ import { formatError, formatResult } from "./formatters.js"; import { checkGitStatus } from "./git.js"; import { handleInteractive } from "./interactive.js"; import { resolveInitContext } from "./preflight.js"; -import { createWizardSpinner } from "./spinner.js"; import { forwardFreshTtyToStdin } from "./stdin-reopen.js"; import { describeTool, executeTool } from "./tools/registry.js"; import type { @@ -46,22 +50,23 @@ import type { WizardOptions, WorkflowRunResult, } from "./types.js"; +import { getUI } from "./ui/factory.js"; +import type { SpinnerHandle, WizardUI } from "./ui/types.js"; import { precomputeDirListing, precomputeSentryDetection, preReadCommonFiles, } from "./workflow-inputs.js"; -type Spinner = ReturnType; - type SpinState = { running: boolean }; type StepContext = { payload: SuspendPayload; stepId: string; - spin: Spinner; + spin: SpinnerHandle; spinState: SpinState; context: ResolvedInitContext; + ui: WizardUI; }; function nextPhase( @@ -173,7 +178,7 @@ async function handleSuspendedStep( stepPhases: Map, stepHistory: Map[]> ): Promise> { - const { payload, stepId, spin, spinState, context } = ctx; + const { payload, stepId, spin, spinState, context, ui } = ctx; const label = STEP_LABELS[stepId] ?? stepId; if (payload.type === "tool") { @@ -226,7 +231,7 @@ async function handleSuspendedStep( spin.stop(label); spinState.running = false; - const interactiveResult = await handleInteractive(payload, context); + const interactiveResult = await handleInteractive(payload, context, ui); spin.start("Processing..."); spinState.running = true; @@ -239,10 +244,10 @@ async function handleSuspendedStep( spin.stop("Error", 1); spinState.running = false; - log.error( + ui.log.error( `Unknown suspend payload type "${(payload as { type: string }).type}"` ); - cancel("Setup failed"); + ui.cancel("Setup failed"); throw new WizardCancelledError(); } @@ -301,22 +306,25 @@ function withTimeout( }); } -async function confirmExperimental(yes: boolean): Promise { +async function confirmExperimental( + yes: boolean, + ui: WizardUI +): Promise { if (yes) { return true; } - const proceed = await confirm({ + const proceed = await ui.confirm({ message: "EXPERIMENTAL: This feature is experimental and may modify your code. Continue?", }); - abortIfCancelled(proceed); - return !!proceed; + return Boolean(abortIfCancelled(proceed)); } async function preamble( directory: string, yes: boolean, - dryRun: boolean + dryRun: boolean, + ui: WizardUI ): Promise { if (!(yes || dryRun || process.stdin.isTTY)) { throw new WizardError( @@ -326,11 +334,11 @@ async function preamble( } process.stderr.write(`\n${formatBanner()}\n\n`); - intro("sentry init"); + ui.intro("sentry init"); let confirmed: boolean; try { - confirmed = await confirmExperimental(yes || dryRun); + confirmed = await confirmExperimental(yes || dryRun, ui); } catch (err) { if (err instanceof WizardCancelledError) { captureException(err); @@ -340,18 +348,22 @@ async function preamble( throw err; } if (!confirmed) { - cancel("Setup cancelled."); + ui.cancel("Setup cancelled."); process.exitCode = 0; return false; } if (dryRun) { - log.warn("Dry-run mode: no files will be modified."); + ui.log.warn("Dry-run mode: no files will be modified."); } - const gitOk = await checkGitStatus({ cwd: directory, yes: yes || dryRun }); + const gitOk = await checkGitStatus({ + cwd: directory, + yes: yes || dryRun, + ui, + }); if (!gitOk) { - cancel("Setup cancelled."); + ui.cancel("Setup cancelled."); process.exitCode = 0; return false; } @@ -386,11 +398,16 @@ export async function runWizard(initialOptions: WizardOptions): Promise { const { directory, yes, dryRun, features } = initialOptions; - if (!(await preamble(directory, yes, dryRun))) { + // Construct the UI once for the entire run; tear down on every exit + // path via `await using`. `getUI()` picks the right implementation + // based on TTY state and `--yes`. + await using ui = getUI({ yes }); + + if (!(await preamble(directory, yes, dryRun, ui))) { return; } - log.info( + ui.log.info( "This wizard uses AI to analyze your project and configure Sentry." + `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` ); @@ -398,7 +415,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { const effectiveOptions = dryRun ? { ...initialOptions, yes: true } : initialOptions; - const context = await resolveInitContext(effectiveOptions); + const context = await resolveInitContext(effectiveOptions, ui); if (!context) { return; } @@ -454,7 +471,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { }); const workflow = client.getWorkflow(WORKFLOW_ID); - const spin = createWizardSpinner(); + const spin = ui.spinner(); const spinState: SpinState = { running: false }; spin.start("Scanning project..."); @@ -500,8 +517,8 @@ export async function runWizard(initialOptions: WizardOptions): Promise { } catch (err) { spin.stop("Connection failed", 1); spinState.running = false; - log.error(errorMessage(err)); - cancel("Setup failed"); + ui.log.error(errorMessage(err)); + ui.cancel("Setup failed"); throw new WizardError(errorMessage(err)); } @@ -517,8 +534,8 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (!extracted) { spin.stop("Error", 1); spinState.running = false; - log.error(`No suspend payload found for step "${stepId}"`); - cancel("Setup failed"); + ui.log.error(`No suspend payload found for step "${stepId}"`); + ui.cancel("Setup failed"); throw new WizardError(`No suspend payload found for step "${stepId}"`); } @@ -529,6 +546,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { spin, spinState, context, + ui, }, stepPhases, stepHistory @@ -565,18 +583,19 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (err instanceof WizardError) { throw err; } - log.error(errorMessage(err)); - cancel("Setup failed"); + ui.log.error(errorMessage(err)); + ui.cancel("Setup failed"); throw new WizardError(errorMessage(err)); } - handleFinalResult(result, spin, spinState); + handleFinalResult(result, spin, spinState, ui); } function handleFinalResult( result: WorkflowRunResult, - spin: Spinner, - spinState: SpinState + spin: SpinnerHandle, + spinState: SpinState, + ui: WizardUI ): void { const hasError = result.status !== "success" || result.result?.exitCode; @@ -585,7 +604,7 @@ function handleFinalResult( spin.stop("Failed", 1); spinState.running = false; } - formatError(result); + formatError(result, ui); throw new WizardError("Workflow returned an error"); } @@ -593,7 +612,7 @@ function handleFinalResult( spin.stop("Done"); spinState.running = false; } - formatResult(result); + formatResult(result, ui); } function extractSuspendPayload( diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index baf2e9926..0c19834d7 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -1,81 +1,78 @@ /** * Formatters Tests * - * Tests for the init wizard output formatters. Since formatResult and - * formatError write to clack's output, we capture calls via spyOn on - * the imported @clack/prompts module. + * Tests for the init wizard output formatters. Uses `MockUI` to capture + * every UI call without going through clack. */ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; +import { describe, expect, test } from "bun:test"; import { formatError, formatResult } from "../../../src/lib/init/formatters.js"; - -// Spy on clack functions to capture arguments without replacing them -let logMessageSpy: ReturnType; -let outroSpy: ReturnType; -let cancelSpy: ReturnType; -let logInfoSpy: ReturnType; -let logWarnSpy: ReturnType; -let logErrorSpy: ReturnType; - -const noop = () => { - /* suppress clack output */ -}; - -let savedPlainOutput: string | undefined; - -beforeEach(() => { - // Force rich output so clack-plain.ts delegates to real clack (spied below) - savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; - process.env.SENTRY_PLAIN_OUTPUT = "0"; - - logMessageSpy = spyOn(clack.log, "message").mockImplementation(noop); - outroSpy = spyOn(clack, "outro").mockImplementation(noop); - cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); - logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); - logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); - logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); -}); - -afterEach(() => { - logMessageSpy.mockRestore(); - outroSpy.mockRestore(); - cancelSpy.mockRestore(); - logInfoSpy.mockRestore(); - logWarnSpy.mockRestore(); - logErrorSpy.mockRestore(); - - if (savedPlainOutput === undefined) { - delete process.env.SENTRY_PLAIN_OUTPUT; - } else { - process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; - } -}); +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; + +function logMessage(calls: MockCall[]): string | undefined { + const call = calls.find((c) => c.kind === "log.message"); + return call?.kind === "log.message" ? call.message : undefined; +} + +function warnMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "log.warn" + ) + .map((c) => c.message); +} + +function errorMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => + c.kind === "log.error" + ) + .map((c) => c.message); +} + +function infoMessages(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "log.info" + ) + .map((c) => c.message); +} describe("formatResult", () => { test("displays summary with all fields and a nested changed-files tree", () => { - formatResult({ - status: "success", - result: { - platform: "Next.js", - projectDir: "/app", - features: ["errorMonitoring", "performanceMonitoring"], - commands: ["npm install @sentry/nextjs"], - sentryProjectUrl: "https://sentry.io/project", - docsUrl: "https://docs.sentry.io", - changedFiles: [ - { action: "modify", path: "next.config.js" }, - { action: "create", path: "src/app/instrumentation-client.ts" }, - { action: "modify", path: "src/app/layout.tsx" }, - { action: "delete", path: "src/old-sentry.js" }, - ], + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + projectDir: "/app", + features: ["errorMonitoring", "performanceMonitoring"], + commands: ["npm install @sentry/nextjs"], + sentryProjectUrl: "https://sentry.io/project", + docsUrl: "https://docs.sentry.io", + changedFiles: [ + { action: "modify", path: "next.config.js" }, + { action: "create", path: "src/app/instrumentation-client.ts" }, + { action: "modify", path: "src/app/layout.tsx" }, + { action: "delete", path: "src/old-sentry.js" }, + ], + }, }, - }); + ui + ); - expect(logMessageSpy).toHaveBeenCalledTimes(1); - const content: string = logMessageSpy.mock.calls[0][0]; + const content = logMessage(calls); + expect(content).toBeDefined(); + if (!content) { + throw new Error("expected log.message call"); + } + // `formatResult` passes raw markdown to `ui.log.message` — color tags + // (`+`, `-`, `\~`) survive + // verbatim because the WizardUI implementation owns rendering. The + // assertions match the unrendered markdown source. expect(content).toContain("Next.js"); expect(content).toContain("/app"); expect(content).toContain("Error Monitoring"); @@ -91,102 +88,121 @@ describe("formatResult", () => { expect(content).toContain("Changed files\n├─ src/"); expect(content).toContain("├─ src/"); expect(content).toContain("│ ├─ app/"); - expect(content).toContain("│ │ ├─ + instrumentation-client.ts"); - expect(content).toContain("│ │ └─ ~ layout.tsx"); - expect(content).toContain("└─ ~ next.config.js"); + expect(content).toContain( + "│ │ ├─ + instrumentation-client.ts" + ); + expect(content).toContain("│ │ └─ \\~ layout.tsx"); + expect(content).toContain("└─ \\~ next.config.js"); const changedFilesSection = content.slice(content.indexOf("Changed files")); expect(changedFilesSection).toContain("│"); - expect(content).not.toContain("`"); }); test("skips summary when result has no summary fields", () => { - formatResult({ status: "success" }); + const { ui, calls } = createMockUI(); + formatResult({ status: "success" }, ui); - expect(logMessageSpy).not.toHaveBeenCalled(); - expect(outroSpy).toHaveBeenCalled(); + expect(logMessage(calls)).toBeUndefined(); + expect(calls.some((c) => c.kind === "outro")).toBe(true); }); test("displays warnings when present", () => { - formatResult({ - status: "success", - result: { - warnings: ["Source maps not configured", "Missing DSN"], + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + warnings: ["Source maps not configured", "Missing DSN"], + }, }, - }); + ui + ); - expect(logWarnSpy).toHaveBeenCalledTimes(2); - expect(logWarnSpy.mock.calls[0][0]).toBe("Source maps not configured"); - expect(logWarnSpy.mock.calls[1][0]).toBe("Missing DSN"); + const warns = warnMessages(calls); + expect(warns).toContain("Source maps not configured"); + expect(warns).toContain("Missing DSN"); }); test("unwraps nested result property", () => { - formatResult({ status: "success", result: { platform: "React" } }); + const { ui, calls } = createMockUI(); + formatResult({ status: "success", result: { platform: "React" } }, ui); - const content: string = logMessageSpy.mock.calls[0][0]; - expect(content).toContain("React"); + expect(logMessage(calls)).toContain("React"); }); }); describe("formatError", () => { test("logs the error message", () => { - formatError({ status: "failed", error: "Connection timed out" }); + const { ui, calls } = createMockUI(); + formatError({ status: "failed", error: "Connection timed out" }, ui); - expect(logErrorSpy).toHaveBeenCalledWith("Connection timed out"); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(errorMessages(calls)).toContain("Connection timed out"); + const cancel = calls.find((c) => c.kind === "cancel"); + expect(cancel?.kind === "cancel" && cancel.message).toBe("Setup failed"); }); test("extracts message from nested result.message", () => { - formatError({ status: "failed", result: { message: "Inner failure" } }); + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { message: "Inner failure" } }, ui); - expect(logErrorSpy).toHaveBeenCalledWith("Inner failure"); + expect(errorMessages(calls)).toContain("Inner failure"); }); test("falls back to unknown error when no message available", () => { - formatError({ status: "failed" }); + const { ui, calls } = createMockUI(); + formatError({ status: "failed" }, ui); - expect(logErrorSpy).toHaveBeenCalledWith( + expect(errorMessages(calls)).toContain( "Wizard failed with an unknown error" ); }); test("shows platform hint for detection failure exit code (20)", () => { - formatError({ status: "failed", result: { exitCode: 20 } }); + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { exitCode: 20 } }, ui); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; - expect(warnMsg).toContain("platform"); + expect(warnMessages(calls).some((m) => m.includes("platform"))).toBe(true); }); test("shows manual install commands for dependency failure (30)", () => { - formatError({ - status: "failed", - result: { - exitCode: 30, - commands: ["npm install @sentry/node"], + const { ui, calls } = createMockUI(); + formatError( + { + status: "failed", + result: { + exitCode: 30, + commands: ["npm install @sentry/node"], + }, }, - }); + ui + ); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; - expect(warnMsg).toContain("$ npm install @sentry/node"); + expect( + warnMessages(calls).some((m) => m.includes("$ npm install @sentry/node")) + ).toBe(true); }); test("shows verification hint for exit code 50", () => { - formatError({ status: "failed", result: { exitCode: 50 } }); + const { ui, calls } = createMockUI(); + formatError({ status: "failed", result: { exitCode: 50 } }, ui); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; - expect(warnMsg).toContain("verification"); + expect(warnMessages(calls).some((m) => m.includes("verification"))).toBe( + true + ); }); test("shows docs URL when present", () => { - formatError({ - status: "failed", - result: { docsUrl: "https://docs.sentry.io/platforms/react/" }, - }); + const { ui, calls } = createMockUI(); + formatError( + { + status: "failed", + result: { docsUrl: "https://docs.sentry.io/platforms/react/" }, + }, + ui + ); - const infoCalls = logInfoSpy.mock.calls.map((c) => String(c[0])); + const infos = infoMessages(calls); expect( - infoCalls.some((s) => - s.includes("https://docs.sentry.io/platforms/react/") - ) + infos.some((s) => s.includes("https://docs.sentry.io/platforms/react/")) ).toBe(true); }); }); diff --git a/test/lib/init/git.test.ts b/test/lib/init/git.test.ts index 4389f4739..bc92df1d1 100644 --- a/test/lib/init/git.test.ts +++ b/test/lib/init/git.test.ts @@ -1,53 +1,42 @@ +/** + * Tests for `checkGitStatus`. Stubs the low-level git probes from + * `src/lib/git.ts` and uses `MockUI` to record/replay all UI traffic. + */ + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as gitLib from "../../../src/lib/git.js"; import { checkGitStatus, getUncommittedOrUntrackedFiles, isInsideGitWorkTree, } from "../../../src/lib/init/git.js"; - -const noop = () => { - /* suppress output */ -}; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; let isInsideWorkTreeSpy: ReturnType; let getUncommittedFilesSpy: ReturnType; -let confirmSpy: ReturnType; -let isCancelSpy: ReturnType; -let logWarnSpy: ReturnType; -let savedPlainOutput: string | undefined; beforeEach(() => { - // Force rich output so clack-plain.ts delegates to real clack (spied below) - savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; - process.env.SENTRY_PLAIN_OUTPUT = "0"; - isInsideWorkTreeSpy = spyOn(gitLib, "isInsideGitWorkTree"); getUncommittedFilesSpy = spyOn(gitLib, "getUncommittedFiles"); - confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true); - isCancelSpy = spyOn(clack, "isCancel").mockImplementation( - (v: unknown) => v === Symbol.for("cancel") - ); - logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); }); afterEach(() => { isInsideWorkTreeSpy.mockRestore(); getUncommittedFilesSpy.mockRestore(); - confirmSpy.mockRestore(); - isCancelSpy.mockRestore(); - logWarnSpy.mockRestore(); - - if (savedPlainOutput === undefined) { - delete process.env.SENTRY_PLAIN_OUTPUT; - } else { - process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; - } }); +function lastWarn(calls: MockCall[]): string | undefined { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i]; + if (call?.kind === "log.warn") { + return call.message; + } + } + return; +} + describe("isInsideGitWorkTree", () => { test("returns true when inside git work tree", () => { isInsideWorkTreeSpy.mockReturnValue(true); @@ -88,81 +77,82 @@ describe("checkGitStatus", () => { isInsideWorkTreeSpy.mockReturnValue(true); getUncommittedFilesSpy.mockReturnValue([]); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const { ui, calls } = createMockUI(); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(true); - expect(confirmSpy).not.toHaveBeenCalled(); - expect(logWarnSpy).not.toHaveBeenCalled(); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); + expect(calls.some((c) => c.kind === "log.warn")).toBe(false); }); test("prompts when not in git repo (interactive) and returns true on confirm", async () => { isInsideWorkTreeSpy.mockReturnValue(false); - confirmSpy.mockResolvedValue(true); + const { ui, calls, respond } = createMockUI(); + respond.confirm(true); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(true); - expect(confirmSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("not inside a git repository"), - }) + const confirmCall = calls.find((c) => c.kind === "confirm"); + expect(confirmCall?.kind === "confirm" && confirmCall.message).toContain( + "not inside a git repository" ); }); test("prompts when not in git repo (interactive) and returns false on decline", async () => { isInsideWorkTreeSpy.mockReturnValue(false); - confirmSpy.mockResolvedValue(false); + const { ui, respond } = createMockUI(); + respond.confirm(false); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(false); }); test("returns false without throwing when user cancels not-in-git-repo prompt", async () => { isInsideWorkTreeSpy.mockReturnValue(false); - confirmSpy.mockResolvedValue(Symbol.for("cancel")); + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(false); }); test("warns and auto-continues when not in git repo with --yes", async () => { isInsideWorkTreeSpy.mockReturnValue(false); + const { ui, calls } = createMockUI(); - const result = await checkGitStatus({ cwd: "/tmp", yes: true }); + const result = await checkGitStatus({ cwd: "/tmp", yes: true, ui }); expect(result).toBe(true); - expect(logWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("not inside a git repository") - ); - expect(confirmSpy).not.toHaveBeenCalled(); + expect(lastWarn(calls)).toContain("not inside a git repository"); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); }); test("shows files and prompts for dirty tree (interactive), returns true on confirm", async () => { isInsideWorkTreeSpy.mockReturnValue(true); getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); - confirmSpy.mockResolvedValue(true); + const { ui, calls, respond } = createMockUI(); + respond.confirm(true); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(true); - expect(logWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("uncommitted") - ); - expect(confirmSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("uncommitted changes"), - }) + expect(lastWarn(calls)).toContain("uncommitted"); + const confirmCall = calls.find((c) => c.kind === "confirm"); + expect(confirmCall?.kind === "confirm" && confirmCall.message).toContain( + "uncommitted changes" ); }); test("shows files and prompts for dirty tree (interactive), returns false on decline", async () => { isInsideWorkTreeSpy.mockReturnValue(true); getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); - confirmSpy.mockResolvedValue(false); + const { ui, respond } = createMockUI(); + respond.confirm(false); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(false); }); @@ -170,9 +160,10 @@ describe("checkGitStatus", () => { test("returns false without throwing when user cancels dirty-tree prompt", async () => { isInsideWorkTreeSpy.mockReturnValue(true); getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts"]); - confirmSpy.mockResolvedValue(Symbol.for("cancel")); + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); - const result = await checkGitStatus({ cwd: "/tmp", yes: false }); + const result = await checkGitStatus({ cwd: "/tmp", yes: false, ui }); expect(result).toBe(false); }); @@ -180,14 +171,15 @@ describe("checkGitStatus", () => { test("warns with file list and auto-continues for dirty tree with --yes", async () => { isInsideWorkTreeSpy.mockReturnValue(true); getUncommittedFilesSpy.mockReturnValue(["- M dirty.ts", "- ?? new.ts"]); + const { ui, calls } = createMockUI(); - const result = await checkGitStatus({ cwd: "/tmp", yes: true }); + const result = await checkGitStatus({ cwd: "/tmp", yes: true, ui }); expect(result).toBe(true); - expect(logWarnSpy).toHaveBeenCalled(); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; - expect(warnMsg).toContain("uncommitted"); - expect(warnMsg).toContain("M dirty.ts"); - expect(confirmSpy).not.toHaveBeenCalled(); + const warn = lastWarn(calls); + expect(warn).toBeDefined(); + expect(warn).toContain("uncommitted"); + expect(warn).toContain("M dirty.ts"); + expect(calls.some((c) => c.kind === "confirm")).toBe(false); }); }); diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index 571f30d3d..6e1892fad 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -1,29 +1,17 @@ /** * Interactive Dispatcher Tests * - * Tests for the init wizard interactive prompt handlers. Uses spyOn on - * @clack/prompts namespace to intercept calls from named imports. + * Tests for the init wizard interactive prompt handlers. Uses a + * `MockUI` that records calls and replays canned prompt responses, so + * the dispatcher can be exercised without touching clack or any real + * terminal. */ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; +import { describe, expect, test } from "bun:test"; import { handleInteractive } from "../../../src/lib/init/interactive.js"; import type { InteractiveContext } from "../../../src/lib/init/types.js"; - -const noop = () => { - /* suppress clack output */ -}; - -let selectSpy: ReturnType; -let multiselectSpy: ReturnType; -let confirmSpy: ReturnType; -let logInfoSpy: ReturnType; -let logErrorSpy: ReturnType; -let logWarnSpy: ReturnType; -let cancelSpy: ReturnType; -let isCancelSpy: ReturnType; -let savedPlainOutput: string | undefined; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; +import { createMockUI } from "./ui/mock-ui.js"; function makeOptions( overrides?: Partial @@ -35,51 +23,13 @@ function makeOptions( }; } -beforeEach(() => { - // Force rich output so clack-plain.ts delegates to real clack (spied below) - savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; - process.env.SENTRY_PLAIN_OUTPUT = "0"; - - selectSpy = spyOn(clack, "select").mockImplementation( - () => Promise.resolve("default") as any - ); - multiselectSpy = spyOn(clack, "multiselect").mockImplementation( - () => Promise.resolve([]) as any - ); - confirmSpy = spyOn(clack, "confirm").mockImplementation( - () => Promise.resolve(true) as any - ); - logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); - logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); - logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); - cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); - isCancelSpy = spyOn(clack, "isCancel").mockImplementation( - (v: unknown) => v === Symbol.for("cancel") - ); -}); - -afterEach(() => { - selectSpy.mockRestore(); - multiselectSpy.mockRestore(); - confirmSpy.mockRestore(); - logInfoSpy.mockRestore(); - logErrorSpy.mockRestore(); - logWarnSpy.mockRestore(); - cancelSpy.mockRestore(); - isCancelSpy.mockRestore(); - - if (savedPlainOutput === undefined) { - delete process.env.SENTRY_PLAIN_OUTPUT; - } else { - process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; - } -}); - describe("handleInteractive dispatcher", () => { test("returns cancelled for unknown kind", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", prompt: "test", kind: "unknown" as "select" }, - makeOptions() + makeOptions(), + ui ); expect(result).toEqual({ cancelled: true }); }); @@ -87,6 +37,7 @@ describe("handleInteractive dispatcher", () => { describe("handleSelect", () => { test("auto-selects single option with --yes", async () => { + const { ui, calls } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -94,14 +45,18 @@ describe("handleSelect", () => { kind: "select", options: ["my-app"], }, - makeOptions({ yes: true }) + makeOptions({ yes: true }), + ui ); expect(result).toEqual({ selectedApp: "my-app" }); - expect(logInfoSpy).toHaveBeenCalled(); + expect( + calls.some((c) => c.kind === "log.info" && c.message.includes("my-app")) + ).toBe(true); }); test("cancels with --yes when multiple options exist", async () => { + const { ui, calls } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -109,14 +64,16 @@ describe("handleSelect", () => { kind: "select", options: ["react", "vue"], }, - makeOptions({ yes: true }) + makeOptions({ yes: true }), + ui ); expect(result).toEqual({ cancelled: true }); - expect(logErrorSpy).toHaveBeenCalled(); + expect(calls.some((c) => c.kind === "log.error")).toBe(true); }); test("cancels when options list is empty", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -124,13 +81,15 @@ describe("handleSelect", () => { kind: "select", options: [], }, - makeOptions() + makeOptions(), + ui ); expect(result).toEqual({ cancelled: true }); }); test("uses apps array names when options not provided", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -138,14 +97,16 @@ describe("handleSelect", () => { kind: "select", apps: [{ name: "express-app", path: "/app", framework: "Express" }], }, - makeOptions({ yes: true }) + makeOptions({ yes: true }), + ui ); expect(result).toEqual({ selectedApp: "express-app" }); }); - test("calls clack select in interactive mode", async () => { - selectSpy.mockImplementation(() => Promise.resolve("vue") as any); + test("calls ui.select in interactive mode", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("vue"); const result = await handleInteractive( { @@ -154,17 +115,17 @@ describe("handleSelect", () => { kind: "select", options: ["react", "vue"], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ); expect(result).toEqual({ selectedApp: "vue" }); - expect(selectSpy).toHaveBeenCalled(); + expect(calls.some((c) => c.kind === "select")).toBe(true); }); test("throws WizardCancelledError on user cancellation", async () => { - selectSpy.mockImplementation( - () => Promise.resolve(Symbol.for("cancel")) as any - ); + const { ui, respond } = createMockUI(); + respond.select(CANCELLED); await expect( handleInteractive( @@ -174,7 +135,8 @@ describe("handleSelect", () => { kind: "select", options: ["react", "vue"], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ) ).rejects.toThrow("Setup cancelled"); }); @@ -182,6 +144,7 @@ describe("handleSelect", () => { describe("handleMultiSelect", () => { test("auto-selects all features with --yes", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -193,7 +156,8 @@ describe("handleMultiSelect", () => { "sessionReplay", ], }, - makeOptions({ yes: true }) + makeOptions({ yes: true }), + ui ); expect(result.features).toEqual([ @@ -204,6 +168,7 @@ describe("handleMultiSelect", () => { }); test("returns empty features when none available", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -211,7 +176,8 @@ describe("handleMultiSelect", () => { kind: "multi-select", availableFeatures: [], }, - makeOptions() + makeOptions(), + ui ); expect(result).toEqual({ features: [] }); @@ -219,9 +185,8 @@ describe("handleMultiSelect", () => { test("prepends errorMonitoring when available but not user-selected", async () => { // User selects only sessionReplay, but errorMonitoring is available (required) - multiselectSpy.mockImplementation( - () => Promise.resolve(["sessionReplay"]) as any - ); + const { ui, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); const result = await handleInteractive( { @@ -234,7 +199,8 @@ describe("handleMultiSelect", () => { "sessionReplay", ], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ); const features = result.features as string[]; @@ -243,9 +209,8 @@ describe("handleMultiSelect", () => { }); test("throws WizardCancelledError when user cancels multi-select", async () => { - multiselectSpy.mockImplementation( - () => Promise.resolve(Symbol.for("cancel")) as any - ); + const { ui, respond } = createMockUI(); + respond.multiselect(CANCELLED); await expect( handleInteractive( @@ -255,12 +220,14 @@ describe("handleMultiSelect", () => { kind: "multi-select", availableFeatures: ["errorMonitoring", "performanceMonitoring"], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ) ).rejects.toThrow("Setup cancelled"); }); test("returns required feature without calling multiselect when only errorMonitoring available", async () => { + const { ui, calls } = createMockUI(); const result = await handleInteractive( { type: "interactive", @@ -268,17 +235,17 @@ describe("handleMultiSelect", () => { kind: "multi-select", availableFeatures: ["errorMonitoring"], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ); expect(result).toEqual({ features: ["errorMonitoring"] }); - expect(multiselectSpy).not.toHaveBeenCalled(); + expect(calls.some((c) => c.kind === "multiselect")).toBe(false); }); test("excludes errorMonitoring from multiselect options (always included)", async () => { - multiselectSpy.mockImplementation( - () => Promise.resolve(["performanceMonitoring"]) as any - ); + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["performanceMonitoring"]); await handleInteractive( { @@ -287,37 +254,39 @@ describe("handleMultiSelect", () => { kind: "multi-select", availableFeatures: ["errorMonitoring", "performanceMonitoring"], }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ); // The options passed to multiselect should NOT include errorMonitoring - const callArgs = multiselectSpy.mock.calls[0][0] as { - options: Array<{ value: string }>; - }; - const values = callArgs.options.map((o: { value: string }) => o.value); - expect(values).not.toContain("errorMonitoring"); - expect(values).toContain("performanceMonitoring"); + const multiselectCall = calls.find((c) => c.kind === "multiselect") as + | Extract<(typeof calls)[number], { kind: "multiselect" }> + | undefined; + expect(multiselectCall).toBeDefined(); + expect(multiselectCall?.options).not.toContain("errorMonitoring"); + expect(multiselectCall?.options).toContain("performanceMonitoring"); }); }); describe("handleConfirm", () => { test("auto-confirms with action: continue for non-example prompts with --yes", async () => { + const { ui } = createMockUI(); const result = await handleInteractive( { type: "interactive", prompt: "Continue with setup?", kind: "confirm", }, - makeOptions({ yes: true }) + makeOptions({ yes: true }), + ui ); expect(result).toEqual({ action: "continue" }); }); test("throws WizardCancelledError when user cancels confirm", async () => { - confirmSpy.mockImplementation( - () => Promise.resolve(Symbol.for("cancel")) as any - ); + const { ui, respond } = createMockUI(); + respond.confirm(CANCELLED); await expect( handleInteractive( @@ -326,13 +295,15 @@ describe("handleConfirm", () => { prompt: "Continue with setup?", kind: "confirm", }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ) ).rejects.toThrow("Setup cancelled"); }); test("returns action: stop when user declines non-example prompt", async () => { - confirmSpy.mockImplementation(() => Promise.resolve(false) as any); + const { ui, respond } = createMockUI(); + respond.confirm(false); const result = await handleInteractive( { @@ -340,7 +311,8 @@ describe("handleConfirm", () => { prompt: "Continue with setup?", kind: "confirm", }, - makeOptions({ yes: false }) + makeOptions({ yes: false }), + ui ); expect(result).toEqual({ action: "stop" }); diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index 20a02c32d..fb16e0fee 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -1,7 +1,10 @@ +/** + * Tests for `resolveInitContext`. Stubs API and DSN-detection layers + * with `spyOn` and uses `MockUI` to drive prompts deterministically. + */ + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as auth from "../../../src/lib/db/auth.js"; @@ -12,10 +15,12 @@ import { ApiError } from "../../../src/lib/errors.js"; import * as prefetch from "../../../src/lib/init/org-prefetch.js"; import { resolveInitContext } from "../../../src/lib/init/preflight.js"; import type { WizardOptions } from "../../../src/lib/init/types.js"; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as resolveTarget from "../../../src/lib/resolve-target.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as resolveTeam from "../../../src/lib/resolve-team.js"; +import { createMockUI } from "./ui/mock-ui.js"; function makeOptions(overrides?: Partial): WizardOptions { return { @@ -26,14 +31,6 @@ function makeOptions(overrides?: Partial): WizardOptions { }; } -const noop = () => { - /* suppress prompt output */ -}; - -let selectSpy: ReturnType; -let isCancelSpy: ReturnType; -let cancelSpy: ReturnType; -let logErrorSpy: ReturnType; let resolveOrgPrefetchedSpy: ReturnType; let listOrganizationsSpy: ReturnType; let getProjectSpy: ReturnType; @@ -42,20 +39,8 @@ let getAuthTokenSpy: ReturnType; let resolveOrCreateTeamSpy: ReturnType; let detectDsnSpy: ReturnType; let resolveDsnByPublicKeySpy: ReturnType; -let savedPlainOutput: string | undefined; beforeEach(() => { - // Force rich output so clack-plain.ts delegates to real clack (spied below) - savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; - process.env.SENTRY_PLAIN_OUTPUT = "0"; - - selectSpy = spyOn(clack, "select").mockResolvedValue("existing"); - isCancelSpy = spyOn(clack, "isCancel").mockImplementation( - (value: unknown) => value === Symbol.for("cancel") - ); - cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); - logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); - resolveOrgPrefetchedSpy = spyOn( prefetch, "resolveOrgPrefetched" @@ -90,10 +75,6 @@ beforeEach(() => { }); afterEach(() => { - selectSpy.mockRestore(); - isCancelSpy.mockRestore(); - cancelSpy.mockRestore(); - logErrorSpy.mockRestore(); resolveOrgPrefetchedSpy.mockRestore(); listOrganizationsSpy.mockRestore(); getProjectSpy.mockRestore(); @@ -103,12 +84,6 @@ afterEach(() => { detectDsnSpy.mockRestore(); resolveDsnByPublicKeySpy.mockRestore(); process.exitCode = 0; - - if (savedPlainOutput === undefined) { - delete process.env.SENTRY_PLAIN_OUTPUT; - } else { - process.env.SENTRY_PLAIN_OUTPUT = savedPlainOutput; - } }); describe("resolveInitContext", () => { @@ -126,7 +101,8 @@ describe("resolveInitContext", () => { project: "my-app", }); - const context = await resolveInitContext(makeOptions()); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); expect(context).toEqual( expect.objectContaining({ @@ -159,7 +135,8 @@ describe("resolveInitContext", () => { }); getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); - const context = await resolveInitContext(makeOptions()); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); expect(context).toEqual( expect.objectContaining({ @@ -194,7 +171,8 @@ describe("resolveInitContext", () => { dateCreated: "2026-04-16T00:00:00Z", } as any); - const context = await resolveInitContext(makeOptions()); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); expect(context?.existingProject).toEqual( expect.objectContaining({ @@ -212,16 +190,19 @@ describe("resolveInitContext", () => { { id: "1", slug: "solo-org", name: "Solo Org" }, ]); - const context = await resolveInitContext(makeOptions({ yes: false })); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); expect(context?.org).toBe("solo-org"); }); test("lets the user choose an existing bare-slug project", async () => { - selectSpy.mockResolvedValue("existing"); + const { ui, respond } = createMockUI(); + respond.select("existing"); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }) + makeOptions({ yes: false, project: "my-app" }), + ui ); expect(context?.project).toBe("my-app"); @@ -231,8 +212,10 @@ describe("resolveInitContext", () => { test("keeps the bare slug when the existence lookup fails", async () => { getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + const { ui } = createMockUI(); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }) + makeOptions({ yes: false, project: "my-app" }), + ui ); expect(context?.project).toBe("my-app"); @@ -242,7 +225,8 @@ describe("resolveInitContext", () => { test("defers empty-org team creation until project creation", async () => { resolveOrCreateTeamSpy.mockResolvedValue({ source: "deferred" } as any); - const context = await resolveInitContext(makeOptions()); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); expect(context?.team).toBeUndefined(); expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( @@ -255,10 +239,12 @@ describe("resolveInitContext", () => { }); test("clears the project when the user chooses to create new", async () => { - selectSpy.mockResolvedValue("create"); + const { ui, respond } = createMockUI(); + respond.select("create"); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }) + makeOptions({ yes: false, project: "my-app" }), + ui ); expect(context?.project).toBeUndefined(); @@ -271,8 +257,10 @@ describe("resolveInitContext", () => { source: options.team ? "explicit" : "auto-selected", })); + const { ui } = createMockUI(); const context = await resolveInitContext( - makeOptions({ team: "backend", yes: false }) + makeOptions({ team: "backend", yes: false }), + ui ); expect(context?.team).toBe("backend"); @@ -286,7 +274,8 @@ describe("resolveInitContext", () => { }); test("uses the ambiguity callback when team selection requires it", async () => { - selectSpy.mockResolvedValue("mobile"); + const { ui, respond } = createMockUI(); + respond.select("mobile"); resolveOrCreateTeamSpy.mockImplementation(async (_org, options) => { const slug = await options.onAmbiguous?.([ { slug: "mobile", name: "Mobile", isMember: true }, @@ -295,7 +284,7 @@ describe("resolveInitContext", () => { return { slug, source: "auto-selected" }; }); - const context = await resolveInitContext(makeOptions({ yes: false })); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); expect(context?.team).toBe("mobile"); }); @@ -306,16 +295,22 @@ describe("resolveInitContext", () => { { id: "1", slug: "acme", name: "Acme" }, { id: "2", slug: "beta", name: "Beta" }, ]); - selectSpy.mockResolvedValue(Symbol.for("cancel")); - const context = await resolveInitContext(makeOptions({ yes: false })); + const { ui, calls, respond } = createMockUI(); + respond.select(CANCELLED); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); expect(context).toBeNull(); - expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); + const cancelCall = calls.find((c) => c.kind === "cancel"); + expect(cancelCall?.kind === "cancel" && cancelCall.message).toBe( + "Setup cancelled." + ); }); test("includes the auth token in the resolved context", async () => { - const context = await resolveInitContext(makeOptions()); + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); expect(context?.authToken).toBe("sntrys_test"); }); diff --git a/test/lib/init/ui/mock-ui.ts b/test/lib/init/ui/mock-ui.ts new file mode 100644 index 000000000..aebfdbd67 --- /dev/null +++ b/test/lib/init/ui/mock-ui.ts @@ -0,0 +1,152 @@ +/** + * MockUI — test double for the `WizardUI` interface. + * + * Records every method call as a JSON-serialisable trace so tests can + * make assertions about ordering, arguments, and call counts. Prompt + * methods are programmable: tests push fake responses onto a queue and + * `MockUI` returns them in order. Empty queue → returns `CANCELLED` so + * cancellation paths are easy to exercise. + * + * Lives in `test/lib/init/ui/` rather than `src/` because it's a + * test-only helper — it should not be bundled into the CLI. + */ + +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOptions, + type SpinnerExitCode, + type SpinnerHandle, + type WizardLog, + type WizardUI, +} from "../../../../src/lib/init/ui/types.js"; + +export type MockCall = + | { kind: "intro"; title: string } + | { kind: "outro"; message: string } + | { kind: "cancel"; message: string } + | { kind: "log.info"; message: string } + | { kind: "log.warn"; message: string } + | { kind: "log.error"; message: string } + | { kind: "log.success"; message: string } + | { kind: "log.message"; message: string } + | { kind: "spinner.start"; message?: string } + | { kind: "spinner.message"; message?: string } + | { kind: "spinner.stop"; message?: string; code?: SpinnerExitCode } + | { kind: "select"; message: string; options: string[] } + | { + kind: "multiselect"; + message: string; + options: string[]; + initialValues?: string[]; + } + | { kind: "confirm"; message: string; initialValue?: boolean }; + +/** + * Programmable prompt response. `value` is what the impl returns when + * the matching prompt method is invoked (or `CANCELLED` to simulate a + * user abort). + */ +export type MockResponse = + | { kind: "select"; value: string | Cancelled } + | { kind: "multiselect"; value: string[] | Cancelled } + | { kind: "confirm"; value: boolean | Cancelled }; + +/** + * Build a mock `WizardUI` plus its observable state. + * + * Returns the impl, the call trace, and a `respond()` helper for + * pushing canned responses onto the prompt queue. + */ +export function createMockUI(): { + ui: WizardUI; + calls: MockCall[]; + respond: { + select(value: string | Cancelled): void; + multiselect(value: string[] | Cancelled): void; + confirm(value: boolean | Cancelled): void; + }; +} { + const calls: MockCall[] = []; + const responses: MockResponse[] = []; + + const log: WizardLog = { + info: (message) => calls.push({ kind: "log.info", message }), + warn: (message) => calls.push({ kind: "log.warn", message }), + error: (message) => calls.push({ kind: "log.error", message }), + success: (message) => calls.push({ kind: "log.success", message }), + message: (message) => calls.push({ kind: "log.message", message }), + }; + + const spinner = (): SpinnerHandle => ({ + start: (message) => calls.push({ kind: "spinner.start", message }), + message: (message) => calls.push({ kind: "spinner.message", message }), + stop: (message, code) => + calls.push({ kind: "spinner.stop", message, code }), + }); + + function takeResponse( + kind: K + ): Extract["value"] | Cancelled { + const next = responses.shift(); + if (!next) { + // Tests that don't push a response get a clean cancel — easier to + // detect mistakes than silent default values. + return CANCELLED; + } + if (next.kind !== kind) { + throw new Error( + `MockUI: expected next response of kind "${kind}" but found "${next.kind}"` + ); + } + return next.value as Extract["value"]; + } + + const ui: WizardUI = { + intro: (title) => calls.push({ kind: "intro", title }), + outro: (message) => calls.push({ kind: "outro", message }), + cancel: (message) => calls.push({ kind: "cancel", message }), + log, + spinner, + select: (opts: SelectOptions) => { + calls.push({ + kind: "select", + message: opts.message, + options: opts.options.map((option) => option.value), + }); + return Promise.resolve(takeResponse("select")); + }, + multiselect: (opts: MultiSelectOptions) => { + calls.push({ + kind: "multiselect", + message: opts.message, + options: opts.options.map((option) => option.value), + ...(opts.initialValues ? { initialValues: opts.initialValues } : {}), + }); + return Promise.resolve(takeResponse("multiselect")); + }, + confirm: (opts: ConfirmOptions) => { + calls.push({ + kind: "confirm", + message: opts.message, + ...(opts.initialValue !== undefined + ? { initialValue: opts.initialValue } + : {}), + }); + return Promise.resolve(takeResponse("confirm")); + }, + [Symbol.asyncDispose]: () => Promise.resolve(), + }; + + return { + ui, + calls, + respond: { + select: (value) => responses.push({ kind: "select", value }), + multiselect: (value) => responses.push({ kind: "multiselect", value }), + confirm: (value) => responses.push({ kind: "confirm", value }), + }, + }; +} diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index a8d9a9ddc..580e2d57e 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -7,8 +7,6 @@ import { spyOn, test, } from "bun:test"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as banner from "../../../src/lib/banner.js"; @@ -23,8 +21,6 @@ import * as inter from "../../../src/lib/init/interactive.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as preflight from "../../../src/lib/init/preflight.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as initSpinner from "../../../src/lib/init/spinner.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as registry from "../../../src/lib/init/tools/registry.js"; import type { ResolvedInitContext, @@ -32,20 +28,38 @@ import type { WizardOptions, WorkflowRunResult, } from "../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as uiFactory from "../../../src/lib/init/ui/factory.js"; +import type { + SpinnerHandle, + WizardUI, +} from "../../../src/lib/init/ui/types.js"; import { runWizard } from "../../../src/lib/init/wizard-runner.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as workflowInputs from "../../../src/lib/init/workflow-inputs.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; const noop = () => { /* suppress output */ }; -const spinnerMock = { +/** + * Per-test reference to the spinner mock. The wizard-runner calls + * `ui.spinner()` exactly once and reuses the handle for the entire run, + * so we expose a singleton with mock fns the test cases can assert on. + */ +const spinnerMock: SpinnerHandle & { + start: ReturnType; + stop: ReturnType; + message: ReturnType; +} = { start: mock(), stop: mock(), message: mock(), }; +let mockUICalls: MockCall[]; + function makeOptions(overrides?: Partial): WizardOptions { return { directory: "/tmp/test", @@ -74,14 +88,7 @@ let mockResumeResults: WorkflowRunResult[]; let resumeCallCount = 0; let startAsyncMock: ReturnType; -let introSpy: ReturnType; -let confirmSpy: ReturnType; -let cancelSpy: ReturnType; -let logInfoSpy: ReturnType; -let logWarnSpy: ReturnType; -let logErrorSpy: ReturnType; -let spinnerSpy: ReturnType; - +let getUISpy: ReturnType; let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; @@ -114,20 +121,26 @@ beforeEach(() => { resumeCallCount = 0; process.exitCode = 0; - introSpy = spyOn(clack, "intro").mockImplementation(noop); - confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true); - cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); - logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); - logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); - logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); - spinnerSpy = spyOn(initSpinner, "createWizardSpinner").mockReturnValue( - spinnerMock as any - ); - spinnerMock.start.mockClear(); spinnerMock.stop.mockClear(); spinnerMock.message.mockClear(); + // The wizard runner constructs a UI via `getUI()`. Replace it with a + // MockUI whose spinner() returns the shared `spinnerMock` so tests can + // assert on lifecycle calls. + const { ui, calls, respond } = createMockUI(); + mockUICalls = calls; + // Pre-load a confirm response so the experimental confirm prompt + // resolves to "true" by default — the legacy default before MockUI. + // Tests that exercise `--yes` skip this prompt entirely; the response + // sits unused on the queue and is harmless. + respond.confirm(true); + const wrapped: WizardUI = { + ...ui, + spinner: () => spinnerMock, + }; + getUISpy = spyOn(uiFactory, "getUI").mockReturnValue(wrapped); + formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); formatErrorSpy = spyOn(fmt, "formatError").mockImplementation(noop); @@ -194,14 +207,7 @@ beforeEach(() => { }); afterEach(() => { - introSpy.mockRestore(); - confirmSpy.mockRestore(); - cancelSpy.mockRestore(); - logInfoSpy.mockRestore(); - logWarnSpy.mockRestore(); - logErrorSpy.mockRestore(); - spinnerSpy.mockRestore(); - + getUISpy.mockRestore(); formatBannerSpy.mockRestore(); formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); @@ -225,6 +231,26 @@ afterEach(() => { } }); +function lastCancelMessage(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "cancel") { + return call.message; + } + } + return; +} + +function lastWarn(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "log.warn") { + return call.message; + } + } + return; +} + describe("runWizard", () => { test("formats successful results", async () => { await runWizard(makeOptions()); @@ -255,9 +281,10 @@ describe("runWizard", () => { await runWizard(makeOptions({ dryRun: true, yes: false })); expect(resolveInitContextSpy).toHaveBeenCalledWith( - expect.objectContaining({ dryRun: true, yes: true }) + expect.objectContaining({ dryRun: true, yes: true }), + expect.anything() ); - expect(logWarnSpy).toHaveBeenCalled(); + expect(lastWarn()).toContain("Dry-run"); }); test("stops before workflow creation when preflight returns null", async () => { @@ -274,7 +301,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); - expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); + expect(lastCancelMessage()).toBe("Setup cancelled."); expect(getWorkflowSpy).not.toHaveBeenCalled(); }); @@ -325,7 +352,8 @@ describe("runWizard", () => { kind: "confirm", prompt: "Continue?", }, - makeContext() + makeContext(), + expect.anything() ); }); @@ -401,7 +429,7 @@ describe("runWizard", () => { await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); expect(spinnerMock.stop).toHaveBeenCalledWith("Error", 1); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + expect(lastCancelMessage()).toBe("Setup failed"); }); test("tears down forwarding and stops the spinner on cancellation", async () => { From c365f278e08ccbf3afd2c361131d86121a62ddd7 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:33:11 +0000 Subject: [PATCH 03/20] feat(init): add OpenTuiUI behind --tui flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full-screen OpenTUI WizardUI behind an opt-in `--tui` flag, with stricli's auto-generated `--no-tui` as the escape hatch. `OpenTuiUI` (`src/lib/init/ui/opentui-ui.ts`): - Alternate-screen renderer via `createCliRenderer({ screenMode: "alternate-screen", exitOnCtrlC: false })`. Bypasses OpenTUI's built-in Ctrl+C handler so the wizard can resolve any pending prompt with `CANCELLED` and route exit through `wizard-runner.ts`'s cancellation path (which captures telemetry, etc.). - Four-region vertical layout: header, scrollable log pane, single-line spinner block, prompt area. - `spinner()` returns a handle that drives a periodic Text content update (4-frame cycle, matching `createWizardSpinner` cadence). - `select` mounts a focused `SelectRenderable` and resolves on `itemSelected`. - `multiselect` mounts a `SelectRenderable` with augmented `[x]`/ `[ ]` labels and a custom global keypress handler: space toggles the highlighted option, enter confirms. - `confirm` is a two-option select ("Yes"/"No") with the initial value mapped from the bool input. - `Symbol.asyncDispose` calls `renderer.destroy()` to restore the main screen buffer on every exit path. Factory (`src/lib/init/ui/factory.ts`) gains: - `shouldUseOpenTui()` predicate (Bun runtime + opt-in + not legacy). - New `getUIAsync()` that lazy-imports `@opentui/core` and constructs `OpenTuiUI` when the user opted in. Falls back to `ClackUI` if the import fails (e.g. accidental Node distribution invocation), so a missing native binding never crashes the wizard. - The sync `getUI()` is preserved for non-TUI paths. Wiring: - `runWizard()` now calls `getUIAsync({ yes, preferTui, forceLegacy })`. - `WizardOptions` extended with optional `tui` and `forceLegacyUi`. - `sentry init` gains a `--tui` boolean flag; stricli auto-creates the `--no-tui` negation. `SENTRY_INIT_TUI=1` env var is the same opt-in for programmatic callers. - `wizard-runner.test.ts` updated to spy on `getUIAsync` (was `getUI`). Build / bundle: - `script/bundle.ts` (npm/Node distribution) externalizes `@opentui/core` and `@opentui/core/*`. The lazy import in the factory throws under Node, which the factory catches and downgrades to `ClackUI` — no crash, no warning beyond the user not seeing the TUI. - `script/build.ts` (Bun compile) bundles `@opentui/core` into the native binary alongside its Zig bindings. No script changes needed. 345/345 init/types/commands tests pass; typecheck, ultracite, and `check:deps` all clean. PR 4 will flip the default factory so the Bun binary uses `OpenTuiUI` automatically (preserving `--no-tui` as escape hatch), and remove `ClackUI` + `@clack/prompts`. --- script/bundle.ts | 11 +- src/commands/init.ts | 18 ++ src/lib/init/types.ts | 12 + src/lib/init/ui/factory.ts | 98 ++++-- src/lib/init/ui/opentui-ui.ts | 485 ++++++++++++++++++++++++++++ src/lib/init/wizard-runner.ts | 15 +- test/lib/init/wizard-runner.test.ts | 2 +- 7 files changed, 609 insertions(+), 32 deletions(-) create mode 100644 src/lib/init/ui/opentui-ui.ts diff --git a/script/bundle.ts b/script/bundle.ts index 0949163bb..63d96e76b 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -215,8 +215,15 @@ const result = await build({ // Replace import.meta.url with the injected shim variable for CJS "import.meta.url": "import_meta_url", }, - // Only externalize Node.js built-ins - bundle all npm packages - external: ["node:*"], + // Externalize Node.js built-ins, plus `@opentui/core`. OpenTUI ships + // native Zig bindings that only load under the Bun runtime, so the + // npm/Node distribution must NOT bundle it. The factory in + // `src/lib/init/ui/factory.ts` lazy-imports it and falls back to + // ClackUI on import failure, so marking it external here means a + // Node user simply gets the legacy UI without a crash. The Bun + // compile (script/build.ts) bundles it via Bun.build's `compile` + // step, where the native loader is available. + external: ["node:*", "@opentui/core", "@opentui/core/*"], metafile: true, plugins, }); diff --git a/src/commands/init.ts b/src/commands/init.ts index f1f7dad14..b88ea8383 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -45,6 +45,12 @@ type InitFlags = { readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; + /** + * Boolean opt-in for the OpenTUI full-screen interface. Stricli + * auto-generates a negated `--no-tui` flag for the user-facing + * escape hatch — both forms feed the same boolean value here. + */ + readonly tui: boolean; }; /** @@ -226,6 +232,12 @@ export const initCommand = buildCommand< brief: "Team slug to create the project under", optional: true, }, + tui: { + kind: "boolean", + brief: + "Use the experimental full-screen OpenTUI interface (Bun binary only). Pass --no-tui to force the legacy single-line interface.", + default: false, + }, }, aliases: { ...DRY_RUN_ALIASES, @@ -285,6 +297,12 @@ export const initCommand = buildCommand< team: flags.team, org: explicitOrg, project: explicitProject, + // `--no-tui` is auto-generated by stricli's flag negation: it + // sets `flags.tui` to `false` (vs. the default of `false` when + // unspecified). To distinguish "user wants legacy" from "user + // didn't pass --tui", we treat `tui === true` as opt-in and + // leave `forceLegacyUi` for env-var / programmatic callers. + tui: flags.tui, }); } finally { // 7. macOS-only force-exit safety net. diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 182b3a58b..726b36e6b 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -20,6 +20,18 @@ export type WizardOptions = { team?: string; org?: string; project?: string; + /** + * Opt into the experimental OpenTUI full-screen UI. Mapped from the + * `--tui` CLI flag. Ignored on the npm/Node distribution and in + * non-interactive contexts (`--yes`, piped stdin/stdout). + */ + tui?: boolean; + /** + * Force the legacy non-OpenTUI UI path (`ClackUI` interactively, + * `LoggingUI` non-interactively). Mapped from `--no-tui`. Acts as + * an escape hatch when the OpenTUI path misbehaves. + */ + forceLegacyUi?: boolean; }; export type ResolvedInitContext = { diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts index 8e268da58..74ef42dc4 100644 --- a/src/lib/init/ui/factory.ts +++ b/src/lib/init/ui/factory.ts @@ -8,20 +8,22 @@ * * Selection priority (highest first): * - * 1. `SENTRY_INIT_TUI=0` — force `LoggingUI` (debug escape hatch). - * 2. `--yes` flag set, OR stdin is not a TTY, OR stdout is not a TTY — - * force `LoggingUI` (CI / piped input). - * 3. Running on the npm/Node distribution (not the Bun-compiled binary) - * — force `LoggingUI`. OpenTUI is Bun-only and the Node `dist/bin.cjs` - * has no native binding for it. (Note: `OpenTuiUI` itself doesn't land - * until PR3 — until then this branch falls through to `ClackUI` because - * clack works on both runtimes.) - * 4. `SENTRY_INIT_TUI=1` — force the new TUI (once `OpenTuiUI` exists). - * 5. Default — `ClackUI` (today). PR4 flips this to `OpenTuiUI` once the - * full-screen renderer is ready. + * 1. `SENTRY_INIT_TUI=0` or `forceLegacy` — force the legacy non-OpenTUI + * path (`LoggingUI` for non-interactive, `ClackUI` for interactive). + * Debug escape hatch for users who hit a TUI bug. + * 2. `--yes` flag set, OR stdin/stdout is not a TTY — force `LoggingUI` + * regardless of the requested UI mode. + * 3. Running outside the Bun-compiled binary (i.e. on Node) — fall back + * to `ClackUI` for interactive contexts. OpenTUI ships native Zig + * bindings that the npm `dist/bin.cjs` distribution can't load. + * 4. `--tui` (or `SENTRY_INIT_TUI=1`) and on Bun binary → `OpenTuiUI`. + * 5. Default — `ClackUI` until PR 4 flips this to `OpenTuiUI`. * - * `--no-tui` flag handling lives in `src/commands/init.ts` and maps to - * `SENTRY_INIT_TUI=0` before this factory runs. + * This module exposes both a sync `getUI()` (returns whatever doesn't + * require an async load — `ClackUI`/`LoggingUI`) and an async + * `getUIAsync()` that can return `OpenTuiUI` after the lazy + * `@opentui/core` import resolves. Wizard call sites should use + * `getUIAsync()` when they want the new TUI. */ import { ClackUI } from "./clack-ui.js"; @@ -41,6 +43,12 @@ export type UIFactoryOptions = { * the caller force `ClackUI`/`LoggingUI` without poking env vars. */ forceLegacy?: boolean; + /** + * True when the user explicitly opted into the new TUI via `--tui`. + * Ignored on the npm/Node distribution (where OpenTUI's native + * bindings aren't available) and in non-interactive contexts. + */ + preferTui?: boolean; }; /** @@ -49,7 +57,7 @@ export type UIFactoryOptions = { * distribution. The `Bun` global only exists in the Bun runtime. * * Exported for the test suite — production callers should go through - * `getUI()`. + * `getUI()` / `getUIAsync()`. */ export function isBunRuntime(): boolean { return ( @@ -87,24 +95,66 @@ function shouldUseLogging(opts: UIFactoryOptions): boolean { } /** - * Construct the `WizardUI` instance for this run. + * Decide whether the caller wants the OpenTUI implementation. + * + * This is true only when the user explicitly opted in (`--tui` flag or + * `SENTRY_INIT_TUI=1`), the runtime is the Bun binary, and the + * `forceLegacy` escape hatch is not set. + */ +function shouldUseOpenTui(opts: UIFactoryOptions): boolean { + if (opts.forceLegacy) { + return false; + } + if (!isBunRuntime()) { + return false; + } + if (opts.preferTui === true) { + return true; + } + if (process.env.SENTRY_INIT_TUI === "1") { + return true; + } + return false; +} + +/** + * Synchronous factory — never returns `OpenTuiUI` because that + * implementation requires an async `import("@opentui/core")`. Use + * `getUIAsync()` to opt into the OpenTUI path. * * Callers should treat the return value as an `AsyncDisposable` and use * `await using ui = getUI(...)` to guarantee teardown on every exit - * path. Both current implementations have a no-op disposer, but - * `OpenTuiUI` (PR3) will rely on the dispose protocol to restore the - * main screen buffer and stop its render loop. + * path. */ export function getUI(opts: UIFactoryOptions): WizardUI { if (shouldUseLogging(opts)) { return new LoggingUI(); } - // PR1: interactive runs use ClackUI on both Bun and Node. - // PR3 will replace this branch with `new OpenTuiUI()` when on the - // Bun-compiled binary, falling back to ClackUI on Node — and PR4 - // removes ClackUI altogether. - if (opts.forceLegacy) { - return new ClackUI(); + return new ClackUI(); +} + +/** + * Async factory — picks `OpenTuiUI` when the user opted in and the + * runtime supports it, otherwise delegates to `getUI()`. + * + * 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). + */ +export async function getUIAsync(opts: UIFactoryOptions): Promise { + if (shouldUseLogging(opts)) { + return new LoggingUI(); + } + if (shouldUseOpenTui(opts)) { + try { + const { createOpenTuiUI } = await import("./opentui-ui.js"); + return await createOpenTuiUI(); + } catch { + // Fall through to ClackUI so a missing/broken native binding + // doesn't take down the wizard. The caller can opt into a + // hard-fail by checking `--tui` themselves and calling + // `createOpenTuiUI()` directly. + } } return new ClackUI(); } diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts new file mode 100644 index 000000000..20e3e0e83 --- /dev/null +++ b/src/lib/init/ui/opentui-ui.ts @@ -0,0 +1,485 @@ +/** + * OpenTuiUI — full-screen `WizardUI` implementation built on + * `@opentui/core`. + * + * The renderer takes over the terminal in alternate-screen mode for the + * duration of the run, restoring the main screen on dispose. The layout + * is a vertical flex column: + * + * ┌──────────────────────────────────────────┐ + * │ Header (intro title) │ + * ├──────────────────────────────────────────┤ + * │ Log pane (scrollable, append-only) │ + * │ info: ... │ + * │ warn: ... │ + * │ ... │ + * ├──────────────────────────────────────────┤ + * │ Spinner block (single line, animated) │ + * ├──────────────────────────────────────────┤ + * │ Prompt area (transient — Select/Input) │ + * └──────────────────────────────────────────┘ + * + * Prompt methods mount a focused Select / Input renderable into the + * prompt area, await user input, then unmount it. Cancellation (Ctrl+C + * or Escape) resolves with the shared `CANCELLED` sentinel. + * + * **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; on Node it falls + * back to `ClackUI`. Importing this module on Node will fail at runtime + * when the OpenTUI native loader can't find its binary. + * + * **Lazy import.** The `@opentui/core` import is dynamic — `getUI()` + * builds an `OpenTuiUI` instance asynchronously so the npm bundle + * (which excludes `@opentui/core` from the bundle graph) doesn't see + * the import at module-load time. + */ + +import type { + CliRenderer, + SelectOption as OpenTuiSelectOption, + SelectRenderable, + TextNodeRenderable, +} from "@opentui/core"; +import { renderInlineMarkdown } from "../../formatters/markdown.js"; +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOption, + type SelectOptions, + type SpinnerExitCode, + type SpinnerHandle, + type WizardLog, + type WizardUI, +} from "./types.js"; + +// Spinner frames are kept identical to `src/lib/init/spinner.ts` so the +// tempo and visual rhythm match `ClackUI` users' expectations. +const SPINNER_FRAMES = process.platform.startsWith("win") + ? ["●", "o", "O", "0"] + : ["◒", "◐", "◓", "◑"]; +const SPINNER_INTERVAL_MS = process.platform.startsWith("win") ? 80 : 120; + +const STOP_ICONS: Record = { + 0: "◆", + 1: "■", + 2: "▲", +}; + +/** + * OpenTUI factories used by this module. Resolved once via dynamic + * import in `OpenTuiUI.create()` so the `@opentui/core` import never + * runs synchronously at module-load time on the npm/Node distribution. + * + * The factory return types are intentionally `any` — OpenTUI's vnode + * proxy types are deeply nested generics that don't add safety here + * (the factories are immediately wrapped in our own helpers and the + * resulting renderables are treated as opaque tree nodes). + */ +// biome-ignore lint/suspicious/noExplicitAny: see comment above +type RenderableNode = any; +type OpenTuiFactories = { + createCliRenderer: (config?: unknown) => Promise; + Box: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; + Text: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; + Select: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; +}; + +/** + * Async factory for `OpenTuiUI`. Imports `@opentui/core` lazily and + * constructs the renderer + initial layout. Throws if the native + * bindings are missing (e.g. accidentally invoked from Node). + */ +export async function createOpenTuiUI(): Promise { + const mod = (await import("@opentui/core")) as unknown as OpenTuiFactories; + const renderer = await mod.createCliRenderer({ + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + return new OpenTuiUI(renderer, mod); +} + +/** + * Full-screen WizardUI. See module doc for layout and lifecycle. + * + * Construction is via `createOpenTuiUI()` — the constructor is + * intentionally public to keep the type surface small but should not + * be called directly by feature code. + */ +export class OpenTuiUI implements WizardUI { + private readonly logLines: TextNodeRenderable[] = []; + private readonly logPane: RenderableNode; + private readonly spinnerLine: RenderableNode; + private readonly promptArea: RenderableNode; + private readonly headerLine: RenderableNode; + private spinnerActive = false; + private spinnerTimer: ReturnType | undefined; + private spinnerFrame = 0; + private spinnerMessage = ""; + /** + * Resolver for the currently-active prompt (if any). Set when a + * prompt mounts; cleared when it resolves or is cancelled. We track + * a single "active prompt" because the wizard never nests prompts. + */ + private activePromptResolver: ((value: unknown) => void) | undefined; + private cancelHandlerInstalled = false; + + private readonly renderer: CliRenderer; + private readonly factories: OpenTuiFactories; + + constructor(renderer: CliRenderer, factories: OpenTuiFactories) { + this.renderer = renderer; + this.factories = factories; + const { Box, Text } = factories; + + // Build the four-region column layout. The log pane gets `flexGrow` + // so it consumes any vertical space left over after the fixed-size + // header / spinner / prompt rows. + const root = Box({ flexDirection: "column", flexGrow: 1 }); + this.headerLine = Text({ content: "" }); + this.logPane = Text({ content: "", flexGrow: 1 }); + this.spinnerLine = Text({ content: "" }); + this.promptArea = Box({ flexDirection: "column" }); + + root.add(this.headerLine); + root.add(this.logPane); + root.add(this.spinnerLine); + root.add(this.promptArea); + renderer.root.add(root); + + this.installCancelHandler(); + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + intro(title: string): void { + this.headerLine.content = renderInlineMarkdown(title); + } + + outro(message: string): void { + this.appendLog(`✓ ${message}`); + } + + cancel(message: string): void { + this.appendLog(`✗ ${message}`); + } + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog = { + info: (message) => this.appendLog(`info: ${message}`), + warn: (message) => this.appendLog(`warn: ${message}`), + error: (message) => this.appendLog(`error: ${message}`), + success: (message) => this.appendLog(`✓ ${message}`), + message: (message) => this.appendLog(message), + }; + + // ── Spinner ─────────────────────────────────────────────────────── + + spinner(): SpinnerHandle { + return { + start: (message?: string) => { + this.spinnerActive = true; + this.spinnerFrame = 0; + this.spinnerMessage = message ?? ""; + this.renderSpinnerFrame(); + if (!this.spinnerTimer) { + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.renderSpinnerFrame(); + }, SPINNER_INTERVAL_MS); + } + }, + message: (message?: string) => { + if (this.spinnerActive && message !== undefined) { + this.spinnerMessage = message; + this.renderSpinnerFrame(); + } + }, + stop: (message?: string, code: SpinnerExitCode = 0) => { + if (!this.spinnerActive) { + return; + } + this.spinnerActive = false; + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + const finalMessage = message ?? this.spinnerMessage; + if (finalMessage) { + // Emit the final state into the scrollable log so it survives + // subsequent spinner re-uses, then clear the live spinner row. + this.appendLog(`${STOP_ICONS[code]} ${finalMessage}`); + } + this.spinnerLine.content = ""; + this.spinnerMessage = ""; + }, + }; + } + + // ── Prompts ─────────────────────────────────────────────────────── + + select(opts: SelectOptions): Promise { + return this.runSelectPrompt({ + message: opts.message, + options: opts.options, + initialValue: opts.initialValue, + }); + } + + multiselect( + opts: MultiSelectOptions + ): Promise { + // Multi-select is built on top of `Select` with augmented labels + // ("[x] foo" vs "[ ] foo") and custom keypress handling: space + // toggles, enter confirms. This avoids needing a separate + // multi-select renderable, which OpenTUI doesn't ship. + return this.runMultiSelectPrompt({ + message: opts.message, + options: opts.options, + initial: new Set(opts.initialValues ?? []), + required: opts.required ?? false, + }); + } + + async confirm(opts: ConfirmOptions): Promise { + const result = await this.runSelectPrompt<"yes" | "no">({ + message: opts.message, + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + initialValue: (opts.initialValue ?? true) ? "yes" : "no", + }); + if (result === CANCELLED) { + return CANCELLED; + } + return result === "yes"; + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + // `destroy()` is idempotent and synchronous in OpenTUI's renderer, + // but we wrap in Promise to satisfy the AsyncDisposable contract + // and to leave room for future async teardown work (e.g. drain the + // render queue). + try { + this.renderer.destroy(); + } catch { + // Ignore — disposal must never throw. + } + return Promise.resolve(); + } + + // ── Internal helpers ────────────────────────────────────────────── + + private appendLog(text: string): void { + const { Text } = this.factories; + const line = Text({ + content: renderInlineMarkdown(text), + }) as unknown as TextNodeRenderable; + this.logLines.push(line); + this.logPane.add(line); + } + + private renderSpinnerFrame(): void { + const frame = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0] ?? "•"; + this.spinnerLine.content = renderInlineMarkdown( + `${frame} ${this.spinnerMessage}` + ); + } + + /** + * Mount a `Select` renderable in the prompt area, wait for the user + * to pick an option (or cancel), then clean up. + */ + private runSelectPrompt(opts: { + message: string; + options: SelectOption[]; + initialValue?: T; + }): Promise { + return new Promise((resolve) => { + const { Box, Text, Select } = this.factories; + this.activePromptResolver = resolve as (value: unknown) => void; + + const tuiOptions: OpenTuiSelectOption[] = opts.options.map((option) => ({ + name: option.label, + description: option.hint ?? "", + value: option.value, + })); + const initialIndex = + opts.initialValue !== undefined + ? Math.max( + 0, + opts.options.findIndex( + (option) => option.value === opts.initialValue + ) + ) + : 0; + + const messageNode = Text({ + content: renderInlineMarkdown(opts.message), + }); + const selectNode = Select({ + options: tuiOptions, + selectedIndex: initialIndex, + height: Math.min(opts.options.length + 1, 8), + }); + + const wrapper = Box({ flexDirection: "column" }); + wrapper.add(messageNode); + wrapper.add(selectNode); + this.promptArea.add(wrapper); + + // SelectRenderable extends Renderable which is an EventEmitter. + // `itemSelected` fires when the user presses enter on an option. + const selectRenderable = selectNode as unknown as SelectRenderable; + selectRenderable.focus(); + selectRenderable.on( + "itemSelected", + (_index: number, option: OpenTuiSelectOption) => { + this.tearDownPrompt(wrapper); + resolve(option.value as T); + } + ); + }); + } + + /** + * Mount a `Select` with augmented labels and custom keypress handling + * to support multi-select. Space toggles the highlighted option; + * Enter confirms the selection set. + */ + private runMultiSelectPrompt(opts: { + message: string; + options: SelectOption[]; + initial: Set; + required: boolean; + }): Promise { + return new Promise((resolve) => { + const { Box, Text, Select } = this.factories; + this.activePromptResolver = resolve as (value: unknown) => void; + + const selected = new Set(opts.initial); + + const buildTuiOptions = (): OpenTuiSelectOption[] => + opts.options.map((option) => ({ + name: `[${selected.has(option.value) ? "x" : " "}] ${option.label}`, + description: option.hint ?? "", + value: option.value, + })); + + const messageNode = Text({ + content: renderInlineMarkdown( + `${opts.message}\n(space to toggle, enter to confirm)` + ), + }); + const selectNode = Select({ + options: buildTuiOptions(), + height: Math.min(opts.options.length + 2, 10), + }); + const wrapper = Box({ flexDirection: "column" }); + wrapper.add(messageNode); + wrapper.add(selectNode); + this.promptArea.add(wrapper); + + const selectRenderable = selectNode as unknown as SelectRenderable & { + getSelectedOption: () => OpenTuiSelectOption | null; + // `setOptions` is how SelectRenderable updates its visible options + // — used here to redraw the [x]/[ ] markers when the user toggles. + setOptions?: (options: OpenTuiSelectOption[]) => void; + }; + selectRenderable.focus(); + + // Listen on the renderer's global key input — a focused Select + // already consumes arrow keys and Enter, but space and our cancel + // shortcuts need a global handler so they fire regardless of + // which child is focused. + const toggleHighlighted = () => { + const current = selectRenderable.getSelectedOption(); + if (!current) { + return; + } + const value = current.value as T; + if (selected.has(value)) { + selected.delete(value); + } else { + selected.add(value); + } + selectRenderable.setOptions?.(buildTuiOptions()); + }; + const confirmSelection = () => { + if (opts.required && selected.size === 0) { + return; + } + this.renderer.keyInput.off("keypress", onKey); + this.tearDownPrompt(wrapper); + // Preserve the source option order in the returned array. + const ordered = opts.options + .map((option) => option.value) + .filter((value) => selected.has(value)); + resolve(ordered); + }; + const onKey = (event: { name: string }) => { + if (event.name === "space") { + toggleHighlighted(); + } else if (event.name === "return" || event.name === "enter") { + confirmSelection(); + } + }; + this.renderer.keyInput.on("keypress", onKey); + }); + } + + /** + * Remove a mounted prompt wrapper from the prompt area. + * + * The `activePromptResolver` is cleared so that a follow-up Ctrl+C + * doesn't fire the resolver a second time. + */ + private tearDownPrompt(wrapper: RenderableNode): void { + try { + this.promptArea.remove(wrapper.id); + } catch { + // Renderable may have been unmounted already (e.g. by dispose). + } + this.activePromptResolver = undefined; + } + + /** + * Wire the global Ctrl+C / Escape handler. We bypass OpenTUI's + * built-in `exitOnCtrlC` because the wizard needs cooperative + * cancellation: resolve any pending prompt with `CANCELLED`, then + * let `wizard-runner.ts` bubble the resulting `WizardCancelledError` + * through its catch chain (which captures telemetry, exits cleanly, + * etc.). + */ + private installCancelHandler(): void { + if (this.cancelHandlerInstalled) { + return; + } + this.cancelHandlerInstalled = true; + this.renderer.keyInput.on( + "keypress", + (event: { name: string; ctrl?: boolean }) => { + const isCancel = + (event.ctrl && event.name === "c") || event.name === "escape"; + if (!isCancel) { + return; + } + const resolver = this.activePromptResolver; + if (resolver) { + this.activePromptResolver = undefined; + resolver(CANCELLED); + } + } + ); + } +} diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 3c3ccdbe9..a5f6435b7 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -50,7 +50,7 @@ import type { WizardOptions, WorkflowRunResult, } from "./types.js"; -import { getUI } from "./ui/factory.js"; +import { getUIAsync } from "./ui/factory.js"; import type { SpinnerHandle, WizardUI } from "./ui/types.js"; import { precomputeDirListing, @@ -396,12 +396,17 @@ export async function runWizard(initialOptions: WizardOptions): Promise { }, }; - const { directory, yes, dryRun, features } = initialOptions; + const { directory, yes, dryRun, features, tui, forceLegacyUi } = + initialOptions; // Construct the UI once for the entire run; tear down on every exit - // path via `await using`. `getUI()` picks the right implementation - // based on TTY state and `--yes`. - await using ui = getUI({ yes }); + // path via `await using`. `getUIAsync()` picks the right + // implementation based on TTY state, `--yes`, and the `--tui` opt-in. + await using ui = await getUIAsync({ + yes, + preferTui: tui, + forceLegacy: forceLegacyUi, + }); if (!(await preamble(directory, yes, dryRun, ui))) { return; diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 580e2d57e..fee006821 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -139,7 +139,7 @@ beforeEach(() => { ...ui, spinner: () => spinnerMock, }; - getUISpy = spyOn(uiFactory, "getUI").mockReturnValue(wrapped); + getUISpy = spyOn(uiFactory, "getUIAsync").mockResolvedValue(wrapped); formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); From 424771da933c87447d5c3c5dae897733b860a42e Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:36:41 +0000 Subject: [PATCH 04/20] feat(init): make OpenTuiUI the default and remove ClackUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the factory so interactive runs on the Bun-compiled binary use `OpenTuiUI` automatically, then removes the `ClackUI` implementation and the `@clack/prompts` dependency. Selection rules (post-flip): 1. `SENTRY_INIT_TUI=0` or `--no-tui` → `LoggingUI` (escape hatch) 2. `--yes` / non-TTY → `LoggingUI` 3. Not running under Bun → `LoggingUI` (npm/Node fallback) 4. Default → `OpenTuiUI` The `--tui` flag is still accepted but defaults to `true` — it's now a synonym for the default behavior. `--no-tui` (auto-generated by stricli's flag negation) flips it to `false` and is the user- facing escape hatch. Removed: - `src/lib/init/ui/clack-ui.ts` — the `@clack/prompts` wrapper. - `@clack/prompts` from `devDependencies`. `bun.lock` still pulls it in transitively via `ultracite` but it's no longer in our bundle graph. - The `preferTui` plumbing in `UIFactoryOptions`. `forceLegacy` is now the only signal users / programmatic callers send. - `tui` field from `WizardOptions`; replaced with the inverted `forceLegacyUi` derived from `flags.tui === false`. `clack-utils.ts` no longer imports clack — `abortIfCancelled()` recognises only the unified `CANCELLED` sentinel. `test/lib/init/ui/factory.test.ts` rewritten to exercise `getUIAsync` and the new selection rules. The test cases that previously asserted `ClackUI` was returned now assert `LoggingUI` under `--no-tui` / `SENTRY_INIT_TUI=0` / non-TTY paths, which are the only branches reachable in tests (the OpenTUI path requires a real renderer and is exercised manually). 344/344 init/types/commands tests pass; typecheck, ultracite, and `check:deps` all clean. The npm/Node distribution continues to exclude `@opentui/core` from its bundle (set up in PR 3) so users on the npm package see `LoggingUI` (which throws on prompts — matches the existing CI contract; non-interactive Node users should pass `--yes`). --- bun.lock | 1 - package.json | 1 - src/commands/init.ts | 22 ++--- src/lib/init/clack-utils.ts | 21 ++--- src/lib/init/types.ts | 15 ++-- src/lib/init/ui/clack-ui.ts | 150 ------------------------------- src/lib/init/ui/factory.ts | 123 +++++++++---------------- src/lib/init/ui/types.ts | 31 +++---- src/lib/init/wizard-runner.ts | 9 +- test/lib/init/ui/factory.test.ts | 72 ++++++++------- 10 files changed, 121 insertions(+), 324 deletions(-) delete mode 100644 src/lib/init/ui/clack-ui.ts diff --git a/bun.lock b/bun.lock index 3f915f486..5e35d8470 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", "@opentui/core": "^0.2.0", "@sentry/api": "^0.113.0", diff --git a/package.json b/package.json index 3fdc30f4f..d298427ad 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", "@opentui/core": "^0.2.0", "@sentry/api": "^0.113.0", diff --git a/src/commands/init.ts b/src/commands/init.ts index b88ea8383..3b4fd95b3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -46,9 +46,11 @@ type InitFlags = { readonly features?: string[]; readonly team?: string; /** - * Boolean opt-in for the OpenTUI full-screen interface. Stricli - * auto-generates a negated `--no-tui` flag for the user-facing - * escape hatch — both forms feed the same boolean value here. + * 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. */ readonly tui: boolean; }; @@ -235,8 +237,8 @@ export const initCommand = buildCommand< tui: { kind: "boolean", brief: - "Use the experimental full-screen OpenTUI interface (Bun binary only). Pass --no-tui to force the legacy single-line interface.", - default: false, + "Use the OpenTUI full-screen interface (default on the Bun binary). Pass --no-tui to disable.", + default: true, }, }, aliases: { @@ -297,12 +299,10 @@ export const initCommand = buildCommand< team: flags.team, org: explicitOrg, project: explicitProject, - // `--no-tui` is auto-generated by stricli's flag negation: it - // sets `flags.tui` to `false` (vs. the default of `false` when - // unspecified). To distinguish "user wants legacy" from "user - // didn't pass --tui", we treat `tui === true` as opt-in and - // leave `forceLegacyUi` for env-var / programmatic callers. - tui: flags.tui, + // `flags.tui` defaults to `true`. `--no-tui` (auto-generated + // by stricli's flag negation) flips it to `false` — that's the + // signal we forward to the factory as `forceLegacyUi`. + forceLegacyUi: flags.tui === false, }); } finally { // 7. macOS-only force-exit safety net. diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index 37547e4d4..f94157f42 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -1,19 +1,14 @@ /** * Wizard Utilities * - * Shared cancellation/error helpers and feature labels for the init - * wizard. Originally a clack-specific utility module — the name is - * preserved for now to keep diffs minimal across PRs while the UI - * layer is migrated. PR 4 renames this file to `wizard-utils.ts` after - * the clack dependency is removed. + * Shared cancellation helpers and feature labels for the init wizard. * - * `abortIfCancelled()` recognises **both** the new `WizardUI` - * cancellation sentinel and clack's legacy cancel symbol — the latter - * because `ClackUI` returns the unified sentinel but downstream callers - * may still receive raw clack symbols during the migration window. + * The file name is preserved (vs. renaming to `wizard-utils.ts`) to + * keep the diff in PR 4 focused on the clack removal — the next + * cleanup PR can do the rename. Despite the historical name nothing + * here references clack any more. */ -import { isCancel as clackIsCancel } from "./clack-plain.js"; import { isCancelled } from "./ui/types.js"; export class WizardCancelledError extends Error { @@ -27,17 +22,13 @@ export class WizardCancelledError extends Error { * Coerce a possibly-cancelled prompt result into the resolved value, or * throw `WizardCancelledError` on cancellation. * - * Recognises the unified `CANCELLED` sentinel from `ui/types.ts`. Also - * recognises clack's legacy cancel symbol so callers that still touch - * clack directly continue to work during PR 2. - * * The return type uses `Exclude` so callers passing a union * that includes a symbol member (e.g. `string[] | typeof CANCELLED`) * receive the narrowed non-symbol type back — TypeScript otherwise * widens `T` to the full union and refuses to call array methods on it. */ export function abortIfCancelled(value: T): Exclude { - if (isCancelled(value) || clackIsCancel(value)) { + if (isCancelled(value)) { throw new WizardCancelledError(); } return value as Exclude; diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 726b36e6b..6ab708532 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -21,15 +21,12 @@ export type WizardOptions = { org?: string; project?: string; /** - * Opt into the experimental OpenTUI full-screen UI. Mapped from the - * `--tui` CLI flag. Ignored on the npm/Node distribution and in - * non-interactive contexts (`--yes`, piped stdin/stdout). - */ - tui?: boolean; - /** - * Force the legacy non-OpenTUI UI path (`ClackUI` interactively, - * `LoggingUI` non-interactively). Mapped from `--no-tui`. Acts as - * an escape hatch when the OpenTUI path misbehaves. + * Force the non-OpenTUI fallback (`LoggingUI`). Mapped from + * `--no-tui`. Acts as an escape hatch when the OpenTUI path + * 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 + * every choice via flags. */ forceLegacyUi?: boolean; }; diff --git a/src/lib/init/ui/clack-ui.ts b/src/lib/init/ui/clack-ui.ts deleted file mode 100644 index 716529013..000000000 --- a/src/lib/init/ui/clack-ui.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * ClackUI — interactive WizardUI implementation backed by `@clack/prompts`. - * - * This is the **default** interactive implementation while the OpenTUI - * port is in progress. Its job is to preserve current visible behavior - * (one-line scrolling layout, clack symbol icons, multiline spinner from - * `createWizardSpinner`) while letting the rest of the wizard code call a - * stable `WizardUI` interface. - * - * The wrapper is intentionally thin — it forwards each call to the same - * clack primitives the wizard already uses. When OpenTuiUI lands in PR3 - * and is flipped to default in PR4, this module is deleted along with - * the `@clack/prompts` dependency. - */ - -import { - type Option as ClackOption, - cancel as clackCancel, - confirm as clackConfirm, - intro as clackIntro, - isCancel as clackIsCancel, - log as clackLog, - multiselect as clackMultiSelect, - outro as clackOutro, - select as clackSelect, -} from "@clack/prompts"; -import { renderMarkdown } from "../../formatters/markdown.js"; -import { createWizardSpinner } from "../spinner.js"; -import { - CANCELLED, - type Cancelled, - type ConfirmOptions, - type MultiSelectOptions, - type SelectOption, - type SelectOptions, - type SpinnerHandle, - type WizardLog, - type WizardUI, -} from "./types.js"; - -/** - * Map a `WizardUI` `SelectOption` to clack's `Option` shape. - * - * Clack's `Option` is a conditional type — `Value extends Primitive` - * — and TypeScript will not distribute the conditional through our own - * generic `T extends string`. Asserting the return type lets the wrapper - * compile while preserving correctness (clack's primitive branch matches - * `string` exactly). - * - * Clack types `hint` as an optional property (`hint?: string`) — meaning - * the key must be either omitted or a `string`. Spreading `option.hint` - * into the object as-is would set the key to `undefined`. The conditional - * spread is kept in one place here. - */ -function toClackOption( - option: SelectOption -): ClackOption { - const base = { value: option.value, label: option.label }; - return ( - option.hint === undefined ? base : { ...base, hint: option.hint } - ) as ClackOption; -} - -/** - * Interactive WizardUI backed by clack. See module doc. - */ -export class ClackUI implements WizardUI { - // ── Lifecycle ───────────────────────────────────────────────────── - - intro(title: string): void { - clackIntro(title); - } - - outro(message: string): void { - clackOutro(message); - } - - cancel(message: string): void { - clackCancel(message); - } - - // ── Logging ─────────────────────────────────────────────────────── - - log: WizardLog = { - info: (message: string) => clackLog.info(message), - warn: (message: string) => clackLog.warn(message), - error: (message: string) => clackLog.error(message), - success: (message: string) => clackLog.success(message), - // `log.message` is the caller's plain markdown block — render it here - // so call sites don't need to import the markdown renderer themselves. - message: (message: string) => clackLog.message(renderMarkdown(message)), - }; - - // ── Spinner ─────────────────────────────────────────────────────── - - spinner(): SpinnerHandle { - return createWizardSpinner(); - } - - // ── Prompts ─────────────────────────────────────────────────────── - - async select( - opts: SelectOptions - ): Promise { - const result = await clackSelect({ - message: opts.message, - options: opts.options.map(toClackOption), - initialValue: opts.initialValue, - }); - if (clackIsCancel(result)) { - return CANCELLED; - } - return result; - } - - async multiselect( - opts: MultiSelectOptions - ): Promise { - const result = await clackMultiSelect({ - message: opts.message, - options: opts.options.map(toClackOption), - initialValues: opts.initialValues, - required: opts.required, - }); - if (clackIsCancel(result)) { - return CANCELLED; - } - return result; - } - - async confirm(opts: ConfirmOptions): Promise { - const result = await clackConfirm({ - message: opts.message, - initialValue: opts.initialValue, - }); - if (clackIsCancel(result)) { - return CANCELLED; - } - return Boolean(result); - } - - // ── Disposal ────────────────────────────────────────────────────── - - [Symbol.asyncDispose](): Promise { - // Nothing to tear down — clack writes inline and owns no persistent - // renderer state. Spinners returned from `spinner()` self-clean on - // `stop()`. - return Promise.resolve(); - } -} diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts index 74ef42dc4..01af74da9 100644 --- a/src/lib/init/ui/factory.ts +++ b/src/lib/init/ui/factory.ts @@ -3,30 +3,31 @@ * * Picks the appropriate `WizardUI` implementation based on runtime * environment and CLI flags. This is the single chokepoint for UI - * selection — every part of the init wizard goes through `getUI()` + * selection — every part of the init wizard goes through `getUIAsync()` * rather than instantiating implementations directly. * * Selection priority (highest first): * - * 1. `SENTRY_INIT_TUI=0` or `forceLegacy` — force the legacy non-OpenTUI - * path (`LoggingUI` for non-interactive, `ClackUI` for interactive). - * Debug escape hatch for users who hit a TUI bug. - * 2. `--yes` flag set, OR stdin/stdout is not a TTY — force `LoggingUI` - * regardless of the requested UI mode. - * 3. Running outside the Bun-compiled binary (i.e. on Node) — fall back - * to `ClackUI` for interactive contexts. OpenTUI ships native Zig - * bindings that the npm `dist/bin.cjs` distribution can't load. - * 4. `--tui` (or `SENTRY_INIT_TUI=1`) and on Bun binary → `OpenTuiUI`. - * 5. Default — `ClackUI` until PR 4 flips this to `OpenTuiUI`. + * 1. `--yes` flag set, OR stdin/stdout is not a TTY — `LoggingUI` + * (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 + * 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`. * - * This module exposes both a sync `getUI()` (returns whatever doesn't - * require an async load — `ClackUI`/`LoggingUI`) and an async - * `getUIAsync()` that can return `OpenTuiUI` after the lazy - * `@opentui/core` import resolves. Wizard call sites should use - * `getUIAsync()` when they want the new TUI. + * The previous `ClackUI` implementation was removed in PR 4 once the + * OpenTUI implementation became the default. `@clack/prompts` is no + * longer a dependency. */ -import { ClackUI } from "./clack-ui.js"; import { LoggingUI } from "./logging-ui.js"; import type { WizardUI } from "./types.js"; @@ -39,16 +40,9 @@ export type UIFactoryOptions = { yes: boolean; /** * True when the user explicitly opted out of the new TUI via - * `--no-tui` or the wizard is otherwise unable to use it. This lets - * the caller force `ClackUI`/`LoggingUI` without poking env vars. + * `--no-tui`. Forces `LoggingUI`. */ forceLegacy?: boolean; - /** - * True when the user explicitly opted into the new TUI via `--tui`. - * Ignored on the npm/Node distribution (where OpenTUI's native - * bindings aren't available) and in non-interactive contexts. - */ - preferTui?: boolean; }; /** @@ -57,7 +51,7 @@ export type UIFactoryOptions = { * distribution. The `Bun` global only exists in the Bun runtime. * * Exported for the test suite — production callers should go through - * `getUI()` / `getUIAsync()`. + * `getUIAsync()`. */ export function isBunRuntime(): boolean { return ( @@ -78,83 +72,52 @@ export function isInteractiveTerminal(): boolean { } /** - * Returns `true` when the `LoggingUI` should be used regardless of any - * other signal — i.e. we're in a non-interactive context. + * 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. */ function shouldUseLogging(opts: UIFactoryOptions): boolean { if (process.env.SENTRY_INIT_TUI === "0") { return true; } + if (opts.forceLegacy) { + return true; + } if (opts.yes) { return true; } if (!isInteractiveTerminal()) { return true; } - return false; -} - -/** - * Decide whether the caller wants the OpenTUI implementation. - * - * This is true only when the user explicitly opted in (`--tui` flag or - * `SENTRY_INIT_TUI=1`), the runtime is the Bun binary, and the - * `forceLegacy` escape hatch is not set. - */ -function shouldUseOpenTui(opts: UIFactoryOptions): boolean { - if (opts.forceLegacy) { - return false; - } if (!isBunRuntime()) { - return false; - } - if (opts.preferTui === true) { - return true; - } - if (process.env.SENTRY_INIT_TUI === "1") { return true; } return false; } /** - * Synchronous factory — never returns `OpenTuiUI` because that - * implementation requires an async `import("@opentui/core")`. Use - * `getUIAsync()` to opt into the OpenTUI path. - * - * Callers should treat the return value as an `AsyncDisposable` and use - * `await using ui = getUI(...)` to guarantee teardown on every exit - * path. - */ -export function getUI(opts: UIFactoryOptions): WizardUI { - if (shouldUseLogging(opts)) { - return new LoggingUI(); - } - return new ClackUI(); -} - -/** - * Async factory — picks `OpenTuiUI` when the user opted in and the - * runtime supports it, otherwise delegates to `getUI()`. + * Async factory — picks `OpenTuiUI` 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). * - * 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). + * Callers should treat the return value as an `AsyncDisposable` and + * use `await using ui = await getUIAsync(...)` to guarantee teardown + * on every exit path. */ export async function getUIAsync(opts: UIFactoryOptions): Promise { if (shouldUseLogging(opts)) { return new LoggingUI(); } - if (shouldUseOpenTui(opts)) { - try { - const { createOpenTuiUI } = await import("./opentui-ui.js"); - return await createOpenTuiUI(); - } catch { - // Fall through to ClackUI so a missing/broken native binding - // doesn't take down the wizard. The caller can opt into a - // hard-fail by checking `--tui` themselves and calling - // `createOpenTuiUI()` directly. - } + try { + const { createOpenTuiUI } = await import("./opentui-ui.js"); + return await createOpenTuiUI(); + } 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. + return new LoggingUI(); } - return new ClackUI(); } diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 369d722c6..796100ac0 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -4,23 +4,24 @@ * Defines the I/O surface used by the init wizard. Concrete implementations * provide the actual rendering: * - * - `ClackUI` — current `@clack/prompts`-based interactive UI (default - * while the OpenTUI port is in progress). - * - `OpenTuiUI` — alternate-buffer full-screen UI built on `@opentui/core` - * (Bun-binary only; lands in PR3). - * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, and non-TTY - * environments. Prompts throw — non-interactive callers - * must supply defaults. + * - `OpenTuiUI` — alternate-buffer full-screen UI built on `@opentui/core`. + * Default for interactive runs on the Bun-compiled binary. + * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, non-TTY + * environments, the npm/Node distribution, and the + * `--no-tui` escape hatch. Prompts throw — + * non-interactive callers must supply defaults. + * + * The factory in `factory.ts` picks an implementation per run. * * Goals: - * 1. Mirror clack's API shape so call sites need minimal changes during - * the migration. - * 2. Use a shared cancellation symbol (`CANCELLED`) so all implementations - * can signal cancellation uniformly. Callers wrap prompt results with - * `abortIfCancelled()` (in `clack-utils.ts`) which re-throws as - * `WizardCancelledError`. - * 3. Stay lean — adopt PostHog wizard's `WizardUI` shape for visual - * look-and-feel only, without the screen router / nanostore / health + * 1. Stable prompt API surface so the wizard itself never changes when + * we swap implementations. + * 2. Use a shared cancellation symbol (`CANCELLED`) so all + * implementations can signal cancellation uniformly. Callers wrap + * prompt results with `abortIfCancelled()` (in `clack-utils.ts`) + * which re-throws as `WizardCancelledError`. + * 3. Stay lean — visual look-and-feel inspiration from PostHog wizard's + * `WizardUI` pattern, without the screen router / nanostore / health * check overlays. */ diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index a5f6435b7..f06f9ee7e 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -396,15 +396,14 @@ export async function runWizard(initialOptions: WizardOptions): Promise { }, }; - const { directory, yes, dryRun, features, tui, forceLegacyUi } = - initialOptions; + const { directory, yes, dryRun, features, forceLegacyUi } = initialOptions; // Construct the UI once for the entire run; tear down on every exit - // path via `await using`. `getUIAsync()` picks the right - // implementation based on TTY state, `--yes`, and the `--tui` opt-in. + // path via `await using`. The factory picks `OpenTuiUI` for + // interactive runs on the Bun binary and `LoggingUI` everywhere else + // (CI, `--yes`, `--no-tui`, npm/Node distribution). await using ui = await getUIAsync({ yes, - preferTui: tui, forceLegacy: forceLegacyUi, }); diff --git a/test/lib/init/ui/factory.test.ts b/test/lib/init/ui/factory.test.ts index f47d51ec4..63df35ae6 100644 --- a/test/lib/init/ui/factory.test.ts +++ b/test/lib/init/ui/factory.test.ts @@ -1,20 +1,27 @@ /** - * Tests for getUI() — verifies the runtime-detection rules pick the - * right WizardUI implementation. + * Tests for getUIAsync() — verifies the runtime-detection rules pick + * the right WizardUI implementation. * - * The factory's selection logic depends on three signals: + * The factory's selection logic depends on four 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 * * We patch the env and `process.stdin.isTTY` / `process.stdout.isTTY` - * around each test so the assertions are deterministic. + * 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 + * 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. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { ClackUI } from "../../../../src/lib/init/ui/clack-ui.js"; import { - getUI, + getUIAsync, isInteractiveTerminal, } from "../../../../src/lib/init/ui/factory.js"; import { LoggingUI } from "../../../../src/lib/init/ui/logging-ui.js"; @@ -50,11 +57,6 @@ function restore(snap: TerminalSnapshot): void { } } -function setInteractive(interactive: boolean): void { - (process.stdin as { isTTY: boolean }).isTTY = interactive; - (process.stdout as { isTTY: boolean }).isTTY = interactive; -} - let saved: TerminalSnapshot; beforeEach(() => { @@ -68,7 +70,8 @@ afterEach(() => { describe("isInteractiveTerminal", () => { test("returns true when both stdin and stdout are TTYs", () => { - setInteractive(true); + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; expect(isInteractiveTerminal()).toBe(true); }); @@ -85,52 +88,47 @@ describe("isInteractiveTerminal", () => { }); }); -describe("getUI selection", () => { - test("returns LoggingUI when --yes is set, even on a TTY", () => { - setInteractive(true); - const ui = getUI({ yes: true }); +describe("getUIAsync selection", () => { + test("returns LoggingUI when --yes is set, even on a TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = await getUIAsync({ yes: true }); expect(ui).toBeInstanceOf(LoggingUI); }); - test("returns LoggingUI when stdin is not a TTY", () => { + test("returns LoggingUI when stdin is not a TTY", async () => { (process.stdin as { isTTY: boolean }).isTTY = false; (process.stdout as { isTTY: boolean }).isTTY = true; - const ui = getUI({ yes: false }); + const ui = await getUIAsync({ yes: false }); expect(ui).toBeInstanceOf(LoggingUI); }); - test("returns LoggingUI when stdout is not a TTY", () => { + test("returns LoggingUI when stdout is not a TTY", async () => { (process.stdin as { isTTY: boolean }).isTTY = true; (process.stdout as { isTTY: boolean }).isTTY = false; - const ui = getUI({ yes: false }); + const ui = await getUIAsync({ yes: false }); expect(ui).toBeInstanceOf(LoggingUI); }); - test("returns LoggingUI when SENTRY_INIT_TUI=0 even on interactive TTY", () => { - setInteractive(true); + test("returns LoggingUI when SENTRY_INIT_TUI=0 even on interactive TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; process.env[ENV_KEY] = "0"; - const ui = getUI({ yes: false }); + const ui = await getUIAsync({ yes: false }); expect(ui).toBeInstanceOf(LoggingUI); }); - test("returns ClackUI on interactive TTY without --yes", () => { - setInteractive(true); - const ui = getUI({ yes: false }); - expect(ui).toBeInstanceOf(ClackUI); - }); - - test("returns ClackUI when forceLegacy is set on interactive TTY", () => { - setInteractive(true); - const ui = getUI({ yes: false, forceLegacy: true }); - expect(ui).toBeInstanceOf(ClackUI); + test("returns LoggingUI when forceLegacy is set on interactive TTY", async () => { + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + const ui = await getUIAsync({ yes: false, forceLegacy: true }); + expect(ui).toBeInstanceOf(LoggingUI); }); - test("forceLegacy does not override the non-interactive guard", () => { - // Even with forceLegacy, a non-TTY context must use LoggingUI — - // ClackUI would attempt to read stdin and hang. + test("forceLegacy preserves the LoggingUI choice in non-interactive contexts too", async () => { (process.stdin as { isTTY: boolean }).isTTY = false; (process.stdout as { isTTY: boolean }).isTTY = false; - const ui = getUI({ yes: false, forceLegacy: true }); + const ui = await getUIAsync({ yes: false, forceLegacy: true }); expect(ui).toBeInstanceOf(LoggingUI); }); }); From 44e8c342c9abcc9e7bb579bbf6fee6ce1a84cb5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Apr 2026 18:37:20 +0000 Subject: [PATCH 05/20] chore: regenerate docs --- plugins/sentry-cli/skills/sentry-cli/references/init.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md index a6ad7a0a0..2ee9cfd79 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -20,6 +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.` **Examples:** From e7f7f947e0d95bfdd042ec3180ffc03d9b24c1ae Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:49:24 +0000 Subject: [PATCH 06/20] fix(init): make OpenTuiUI actually render content Two bugs that combined to make the OpenTUI path appear to do nothing when users ran `sentry init` interactively: 1. **Stale VNode references.** The original code used the `Box()` / `Text()` factory functions and stored their return values to mutate later (`this.headerLine.content = x`). Those factories return `ProxiedVNode` proxies that queue calls into a `__pendingCalls` array; the calls only flush at instantiation time when the VNode is added to a parent. Subsequent mutations on the stored VNode reference never reach the live Renderable instance, so the screen stayed blank. Fix: use `BoxRenderable` / `TextRenderable` / `SelectRenderable` constructors directly. They take `(ctx, options)` and return live instances we can mutate in place. `renderer.root.ctx` is the shared RenderContext. 2. **Banner written to stderr bypassed the alternate-screen buffer.** `runWizard` was writing the ASCII banner with `process.stderr.write` before the wizard started. OpenTUI's alternate-screen takeover hides everything that wasn't routed through the renderer, so the banner was invisible and the user's first sight of the wizard was a blank screen. Fix: route the banner through `ui.log.message()` so the OpenTuiUI buffer captures it. 3. **Alternate-screen restore wiped all output on exit.** When the wizard finished and `[Symbol.asyncDispose]()` ran `renderer.destroy()`, the alternate-screen buffer was discarded and the user only saw a fraction of a second of content before the terminal returned to whatever was on the main screen before the wizard started. Fix: maintain a `transcript` array of every intro/log/outro line and replay it to stderr after `destroy()` so the wizard's output appears in scrollback like a normal CLI would. Stderr (rather than stdout) keeps progress chatter out of pipeable wizard output. Verified manually with a small test harness that runs the renderer in-process with forced `isTTY = true` and confirms the rendered characters land in the output stream. 344/344 init/types/commands tests still pass; typecheck clean; ultracite clean. --- src/lib/init/ui/opentui-ui.ts | 171 ++++++++++++++++++++-------------- src/lib/init/wizard-runner.ts | 5 +- 2 files changed, 107 insertions(+), 69 deletions(-) diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index 20e3e0e83..8bd6cfb3c 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -19,27 +19,38 @@ * │ Prompt area (transient — Select/Input) │ * └──────────────────────────────────────────┘ * - * Prompt methods mount a focused Select / Input renderable into the - * prompt area, await user input, then unmount it. Cancellation (Ctrl+C - * or Escape) resolves with the shared `CANCELLED` sentinel. + * Prompt methods mount a focused Select renderable into the prompt + * area, await user input, then unmount it. Cancellation (Ctrl+C or + * Escape) resolves with the shared `CANCELLED` sentinel. * * **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; on Node it falls - * back to `ClackUI`. Importing this module on Node will fail at runtime - * when the OpenTUI native loader can't find its binary. + * back to `LoggingUI`. Importing this module on Node will fail at + * runtime when the OpenTUI native loader can't find its binary. * - * **Lazy import.** The `@opentui/core` import is dynamic — `getUI()` + * **Lazy import.** The `@opentui/core` import is dynamic — `getUIAsync()` * builds an `OpenTuiUI` instance asynchronously so the npm bundle * (which excludes `@opentui/core` from the bundle graph) doesn't see * the import at module-load time. + * + * **Why Renderable classes, not the `Box()`/`Text()` factories.** The + * factory functions return `VNode` proxies that queue mutations into a + * `__pendingCalls` array. Those queued calls only flush at instantiation + * time (when the VNode gets added to a parent). Subsequent mutations on + * the stored VNode reference never reach the live Renderable instance, + * so `vnode.content = "x"` is a no-op after first render. Instantiating + * `BoxRenderable` / `TextRenderable` / `SelectRenderable` directly + * bypasses the proxy and gives us live instances we can mutate in place + * for the spinner tick, log appends, and prompt mount/unmount cycles. */ import type { + BoxRenderable as BoxRenderableType, CliRenderer, SelectOption as OpenTuiSelectOption, - SelectRenderable, - TextNodeRenderable, + SelectRenderable as SelectRenderableType, + TextRenderable as TextRenderableType, } from "@opentui/core"; import { renderInlineMarkdown } from "../../formatters/markdown.js"; import { @@ -56,7 +67,7 @@ import { } from "./types.js"; // Spinner frames are kept identical to `src/lib/init/spinner.ts` so the -// tempo and visual rhythm match `ClackUI` users' expectations. +// tempo and visual rhythm match the legacy LoggingUI users' expectations. const SPINNER_FRAMES = process.platform.startsWith("win") ? ["●", "o", "O", "0"] : ["◒", "◐", "◓", "◑"]; @@ -69,22 +80,25 @@ const STOP_ICONS: Record = { }; /** - * OpenTUI factories used by this module. Resolved once via dynamic - * import in `OpenTuiUI.create()` so the `@opentui/core` import never - * runs synchronously at module-load time on the npm/Node distribution. - * - * The factory return types are intentionally `any` — OpenTUI's vnode - * proxy types are deeply nested generics that don't add safety here - * (the factories are immediately wrapped in our own helpers and the - * resulting renderables are treated as opaque tree nodes). + * OpenTUI Renderable classes used by this module. Resolved once via + * dynamic import in `createOpenTuiUI()` so the `@opentui/core` import + * never runs synchronously at module-load time on the npm/Node + * distribution. */ -// biome-ignore lint/suspicious/noExplicitAny: see comment above -type RenderableNode = any; -type OpenTuiFactories = { +type OpenTuiClasses = { createCliRenderer: (config?: unknown) => Promise; - Box: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; - Text: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; - Select: (props?: unknown, ...children: RenderableNode[]) => RenderableNode; + BoxRenderable: new ( + ctx: unknown, + options: Record + ) => BoxRenderableType; + TextRenderable: new ( + ctx: unknown, + options: Record + ) => TextRenderableType; + SelectRenderable: new ( + ctx: unknown, + options: Record + ) => SelectRenderableType; }; /** @@ -93,7 +107,7 @@ type OpenTuiFactories = { * bindings are missing (e.g. accidentally invoked from Node). */ export async function createOpenTuiUI(): Promise { - const mod = (await import("@opentui/core")) as unknown as OpenTuiFactories; + const mod = (await import("@opentui/core")) as unknown as OpenTuiClasses; const renderer = await mod.createCliRenderer({ exitOnCtrlC: false, screenMode: "alternate-screen", @@ -109,11 +123,12 @@ export async function createOpenTuiUI(): Promise { * be called directly by feature code. */ export class OpenTuiUI implements WizardUI { - private readonly logLines: TextNodeRenderable[] = []; - private readonly logPane: RenderableNode; - private readonly spinnerLine: RenderableNode; - private readonly promptArea: RenderableNode; - private readonly headerLine: RenderableNode; + private readonly renderer: CliRenderer; + private readonly classes: OpenTuiClasses; + private readonly headerLine: TextRenderableType; + private readonly logPane: BoxRenderableType; + private readonly spinnerLine: TextRenderableType; + private readonly promptArea: BoxRenderableType; private spinnerActive = false; private spinnerTimer: ReturnType | undefined; private spinnerFrame = 0; @@ -125,23 +140,35 @@ export class OpenTuiUI implements WizardUI { */ private activePromptResolver: ((value: unknown) => void) | undefined; private cancelHandlerInstalled = false; + /** + * Append-only transcript of every log/intro/outro/cancel line. On + * dispose we write these to stderr after destroying the renderer + * (which restores the main screen) so the user actually sees the + * wizard's output in their scrollback. Without this the alternate- + * screen takeover hides everything the moment `destroy()` returns. + */ + private readonly transcript: string[] = []; - private readonly renderer: CliRenderer; - private readonly factories: OpenTuiFactories; - - constructor(renderer: CliRenderer, factories: OpenTuiFactories) { + constructor(renderer: CliRenderer, classes: OpenTuiClasses) { this.renderer = renderer; - this.factories = factories; - const { Box, Text } = factories; + this.classes = classes; + const ctx = renderer.root.ctx; + const { BoxRenderable, TextRenderable } = classes; // Build the four-region column layout. The log pane gets `flexGrow` // so it consumes any vertical space left over after the fixed-size // header / spinner / prompt rows. - const root = Box({ flexDirection: "column", flexGrow: 1 }); - this.headerLine = Text({ content: "" }); - this.logPane = Text({ content: "", flexGrow: 1 }); - this.spinnerLine = Text({ content: "" }); - this.promptArea = Box({ flexDirection: "column" }); + const root = new BoxRenderable(ctx, { + flexDirection: "column", + flexGrow: 1, + }); + this.headerLine = new TextRenderable(ctx, { content: "" }); + this.logPane = new BoxRenderable(ctx, { + flexDirection: "column", + flexGrow: 1, + }); + this.spinnerLine = new TextRenderable(ctx, { content: "" }); + this.promptArea = new BoxRenderable(ctx, { flexDirection: "column" }); root.add(this.headerLine); root.add(this.logPane); @@ -155,7 +182,9 @@ export class OpenTuiUI implements WizardUI { // ── Lifecycle ───────────────────────────────────────────────────── intro(title: string): void { - this.headerLine.content = renderInlineMarkdown(title); + const rendered = renderInlineMarkdown(title); + this.headerLine.content = rendered; + this.transcript.push(rendered); } outro(message: string): void { @@ -266,27 +295,33 @@ export class OpenTuiUI implements WizardUI { clearInterval(this.spinnerTimer); this.spinnerTimer = undefined; } - // `destroy()` is idempotent and synchronous in OpenTUI's renderer, - // but we wrap in Promise to satisfy the AsyncDisposable contract - // and to leave room for future async teardown work (e.g. drain the - // render queue). + // `destroy()` switches the terminal back from the alternate screen + // to the main screen, which wipes everything OpenTUI rendered. + // Replay the transcript to stderr so the wizard's intro/log lines + // appear in the user's scrollback after exit. Stderr (rather than + // stdout) keeps human-readable progress out of pipeable wizard + // output for any downstream consumers. try { this.renderer.destroy(); } catch { // Ignore — disposal must never throw. } + if (this.transcript.length > 0) { + process.stderr.write(`${this.transcript.join("\n")}\n`); + } return Promise.resolve(); } // ── Internal helpers ────────────────────────────────────────────── private appendLog(text: string): void { - const { Text } = this.factories; - const line = Text({ - content: renderInlineMarkdown(text), - }) as unknown as TextNodeRenderable; - this.logLines.push(line); + const { TextRenderable } = this.classes; + const rendered = renderInlineMarkdown(text); + const line = new TextRenderable(this.renderer.root.ctx, { + content: rendered, + }); this.logPane.add(line); + this.transcript.push(rendered); } private renderSpinnerFrame(): void { @@ -297,7 +332,7 @@ export class OpenTuiUI implements WizardUI { } /** - * Mount a `Select` renderable in the prompt area, wait for the user + * Mount a `SelectRenderable` in the prompt area, wait for the user * to pick an option (or cancel), then clean up. */ private runSelectPrompt(opts: { @@ -306,7 +341,8 @@ export class OpenTuiUI implements WizardUI { initialValue?: T; }): Promise { return new Promise((resolve) => { - const { Box, Text, Select } = this.factories; + const { BoxRenderable, TextRenderable, SelectRenderable } = this.classes; + const ctx = this.renderer.root.ctx; this.activePromptResolver = resolve as (value: unknown) => void; const tuiOptions: OpenTuiSelectOption[] = opts.options.map((option) => ({ @@ -324,25 +360,23 @@ export class OpenTuiUI implements WizardUI { ) : 0; - const messageNode = Text({ + const wrapper = new BoxRenderable(ctx, { flexDirection: "column" }); + const messageNode = new TextRenderable(ctx, { content: renderInlineMarkdown(opts.message), }); - const selectNode = Select({ + const selectNode = new SelectRenderable(ctx, { options: tuiOptions, selectedIndex: initialIndex, height: Math.min(opts.options.length + 1, 8), }); - - const wrapper = Box({ flexDirection: "column" }); wrapper.add(messageNode); wrapper.add(selectNode); this.promptArea.add(wrapper); // SelectRenderable extends Renderable which is an EventEmitter. // `itemSelected` fires when the user presses enter on an option. - const selectRenderable = selectNode as unknown as SelectRenderable; - selectRenderable.focus(); - selectRenderable.on( + selectNode.focus(); + selectNode.on( "itemSelected", (_index: number, option: OpenTuiSelectOption) => { this.tearDownPrompt(wrapper); @@ -353,9 +387,9 @@ export class OpenTuiUI implements WizardUI { } /** - * Mount a `Select` with augmented labels and custom keypress handling - * to support multi-select. Space toggles the highlighted option; - * Enter confirms the selection set. + * Mount a `SelectRenderable` with augmented labels and custom + * keypress handling to support multi-select. Space toggles the + * highlighted option; Enter confirms the selection set. */ private runMultiSelectPrompt(opts: { message: string; @@ -364,7 +398,8 @@ export class OpenTuiUI implements WizardUI { required: boolean; }): Promise { return new Promise((resolve) => { - const { Box, Text, Select } = this.factories; + const { BoxRenderable, TextRenderable, SelectRenderable } = this.classes; + const ctx = this.renderer.root.ctx; this.activePromptResolver = resolve as (value: unknown) => void; const selected = new Set(opts.initial); @@ -376,21 +411,21 @@ export class OpenTuiUI implements WizardUI { value: option.value, })); - const messageNode = Text({ + const wrapper = new BoxRenderable(ctx, { flexDirection: "column" }); + const messageNode = new TextRenderable(ctx, { content: renderInlineMarkdown( `${opts.message}\n(space to toggle, enter to confirm)` ), }); - const selectNode = Select({ + const selectNode = new SelectRenderable(ctx, { options: buildTuiOptions(), height: Math.min(opts.options.length + 2, 10), }); - const wrapper = Box({ flexDirection: "column" }); wrapper.add(messageNode); wrapper.add(selectNode); this.promptArea.add(wrapper); - const selectRenderable = selectNode as unknown as SelectRenderable & { + const selectRenderable = selectNode as SelectRenderableType & { getSelectedOption: () => OpenTuiSelectOption | null; // `setOptions` is how SelectRenderable updates its visible options // — used here to redraw the [x]/[ ] markers when the user toggles. @@ -444,7 +479,7 @@ export class OpenTuiUI implements WizardUI { * The `activePromptResolver` is cleared so that a follow-up Ctrl+C * doesn't fire the resolver a second time. */ - private tearDownPrompt(wrapper: RenderableNode): void { + private tearDownPrompt(wrapper: BoxRenderableType): void { try { this.promptArea.remove(wrapper.id); } catch { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index f06f9ee7e..0205bdde2 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -333,7 +333,10 @@ async function preamble( ); } - process.stderr.write(`\n${formatBanner()}\n\n`); + // Push the banner through the UI so OpenTuiUI's alternate-screen + // buffer captures it. Writing to stderr directly would land on the + // main screen and be hidden by OpenTUI's screen takeover. + ui.log.message(formatBanner()); ui.intro("sentry init"); let confirmed: boolean; From 86dc19cbaa23efaf443062d6e5c64ff1c3e17144 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:57:03 +0000 Subject: [PATCH 07/20] feat(init): polish OpenTuiUI visual design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first OpenTuiUI iteration rendered correctly but looked jagged: ANSI escape codes in messages drew as literal characters, log lines had ugly `info:` / `warn:` text prefixes, and the layout had no visual chrome. Visual changes: - **Rounded border** around the entire wizard area in muted gray. - **Gradient banner** rendered row-by-row inside the header, each row colored with the existing Sentry purple gradient palette (`#B4A4DE` → `#432B8A`). - **Intro line** ("▸ sentry init") in accent purple, separated from the log pane by a thin top-bordered divider Box. - **Iconified, color-coded log lines**: ● light blue — info ▲ amber — warn ✖ soft red — error ✔ mint green — success Two-cell row layout (icon | message) so the icon column never wraps into the message text. - **Spinner** uses the accent purple for the live frames; on stop the row is promoted into the log pane with the matching success/ warn/error icon and color. - **Selects** get `textColor` / `selectedBackgroundColor` / `descriptionColor` props so the focused row is highlighted in accent purple instead of the default white-on-white. - **Multiselect** uses ◉ / ◯ glyphs instead of `[x]` / `[ ]` and shows the keymap hint ("space toggle · enter confirm · esc cancel") in muted text under the prompt. Implementation changes: - **No more `renderInlineMarkdown` for OpenTUI content**. OpenTUI's TextRenderable treats the `content` string as opaque — embedded ANSI escape codes from the markdown renderer were drawn as literal characters, causing the "jagged" look. We now `stripAnsi` every incoming message and apply colors via the `fg` prop on dedicated TextRenderables (one for the icon, one for the text). - **`WizardUI.banner(art)` method**. Banner rendering is now delegated to the implementation: - `OpenTuiUI.banner()` is a no-op — the alternate-screen header already paints the banner in the gradient. - `LoggingUI.banner()` writes the pre-styled ANSI string to stderr (preserving the legacy CI behaviour exactly). `runWizard` calls `ui.banner(formatBanner())` once before `ui.intro`. Previously routing it through `ui.log.message` forced OpenTuiUI to embed the ANSI banner string into its log pane, which broke rendering. - `MockUI` records `banner` calls so existing tests keep passing and future tests can assert on banner ordering. 344/344 init/types/commands tests pass; typecheck, ultracite, and `check:deps` all clean. Verified visually via an in-process test harness — output is now structured, colored, and aligned. --- src/lib/init/ui/logging-ui.ts | 6 + src/lib/init/ui/opentui-ui.ts | 373 +++++++++++++++++++++++++--------- src/lib/init/ui/types.ts | 9 + src/lib/init/wizard-runner.ts | 13 +- test/lib/init/ui/mock-ui.ts | 2 + 5 files changed, 304 insertions(+), 99 deletions(-) diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index b545c3374..d0fb9c0f3 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -80,6 +80,12 @@ export class LoggingUI implements WizardUI { // ── Lifecycle ───────────────────────────────────────────────────── + banner(art: string): void { + // Plain stderr write, no markdown rendering — the banner already + // contains its own ANSI styling and shouldn't be re-processed. + this.stderr.write(`\n${art}\n\n`); + } + intro(title: string): void { this.writeLine(this.stdout, title); } diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index 8bd6cfb3c..5eff12c1e 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -3,46 +3,55 @@ * `@opentui/core`. * * The renderer takes over the terminal in alternate-screen mode for the - * duration of the run, restoring the main screen on dispose. The layout - * is a vertical flex column: + * duration of the run, restoring the main screen on dispose. * - * ┌──────────────────────────────────────────┐ - * │ Header (intro title) │ - * ├──────────────────────────────────────────┤ - * │ Log pane (scrollable, append-only) │ - * │ info: ... │ - * │ warn: ... │ - * │ ... │ - * ├──────────────────────────────────────────┤ - * │ Spinner block (single line, animated) │ - * ├──────────────────────────────────────────┤ - * │ Prompt area (transient — Select/Input) │ - * └──────────────────────────────────────────┘ + * Visual layout: * - * Prompt methods mount a focused Select renderable into the prompt - * area, await user input, then unmount it. Cancellation (Ctrl+C or - * Escape) resolves with the shared `CANCELLED` sentinel. + * ╔══════════════════════════════════════════════════════════════╗ + * ║ ███████╗███████╗███╗ ██╗████████╗██████╗ ██╗ ██╗ ║ banner + * ║ ██╔════╝██╔════╝████╗ ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝ ║ (gradient, + * ║ ███████╗█████╗ ██╔██╗ ██║ ██║ ██████╔╝ ╚████╔╝ ║ one Text + * ║ ╚════██║██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗ ╚██╔╝ ║ per row) + * ║ ███████║███████╗██║ ╚████║ ██║ ██║ ██║ ██║ ║ + * ║ ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ║ + * ║ ║ + * ║ ▸ sentry init ║ intro + * ╠══════════════════════════════════════════════════════════════╣ + * ║ ║ + * ║ ● Auto-confirmed: continuing ║ log pane + * ║ ● Detected platform: javascript-react ║ (icon-prefixed, + * ║ ▲ Source maps not configured ║ colored) + * ║ ║ + * ║ ◒ Installing dependencies… ║ spinner + * ║ ║ + * ║ Which organization should the project be created in? ║ prompt area + * ║ ▸ acme ║ (transient) + * ║ beta ║ + * ╚══════════════════════════════════════════════════════════════╝ + * + * ## Implementation notes + * + * **Renderable classes, not VNode factories.** `Box()`/`Text()`/`Select()` + * factories return `ProxiedVNode`s that queue mutations into a + * `__pendingCalls` array; those calls only flush at instantiation time. + * Mutating a stored VNode reference after first render is a no-op. We + * use `BoxRenderable` / `TextRenderable` / `SelectRenderable` + * constructors directly so we have live instances we can mutate in + * place for spinner ticks, log appends, prompt mount/unmount. + * + * **No ANSI in `content`.** OpenTUI's `TextRenderable` treats its + * `content` string as opaque text — embedded ANSI escape sequences are + * drawn as literal characters, producing the "jagged" look. We strip + * ANSI from incoming messages and apply colors via the `fg` prop on + * separate `TextRenderable`s (one per styled span when needed). * * **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; on Node it falls - * back to `LoggingUI`. Importing this module on Node will fail at - * runtime when the OpenTUI native loader can't find its binary. + * here when running inside the Bun-compiled binary. * - * **Lazy import.** The `@opentui/core` import is dynamic — `getUIAsync()` - * builds an `OpenTuiUI` instance asynchronously so the npm bundle - * (which excludes `@opentui/core` from the bundle graph) doesn't see - * the import at module-load time. - * - * **Why Renderable classes, not the `Box()`/`Text()` factories.** The - * factory functions return `VNode` proxies that queue mutations into a - * `__pendingCalls` array. Those queued calls only flush at instantiation - * time (when the VNode gets added to a parent). Subsequent mutations on - * the stored VNode reference never reach the live Renderable instance, - * so `vnode.content = "x"` is a no-op after first render. Instantiating - * `BoxRenderable` / `TextRenderable` / `SelectRenderable` directly - * bypasses the proxy and gives us live instances we can mutate in place - * for the spinner tick, log appends, and prompt mount/unmount cycles. + * **Lazy import.** The `@opentui/core` import is dynamic so the npm + * bundle (which excludes `@opentui/core` from the bundle graph) + * doesn't see the import at module-load time. */ import type { @@ -52,7 +61,7 @@ import type { SelectRenderable as SelectRenderableType, TextRenderable as TextRenderableType, } from "@opentui/core"; -import { renderInlineMarkdown } from "../../formatters/markdown.js"; +import { stripAnsi } from "../../formatters/plain-detect.js"; import { CANCELLED, type Cancelled, @@ -66,19 +75,65 @@ import { type WizardUI, } from "./types.js"; -// Spinner frames are kept identical to `src/lib/init/spinner.ts` so the -// tempo and visual rhythm match the legacy LoggingUI users' expectations. +// ──────────────────────────── Visual constants ──────────────────────── + +/** Sentry brand purple (used for spinner and accent text). */ +const ACCENT = "#A77DC3"; +/** Muted gray for the chrome border and dim secondary text. */ +const MUTED = "#6E6C7E"; +/** Bright text on dark background. */ +const FOREGROUND = "#E8E6F0"; + +const COLOR_INFO = "#7DD3FC"; // light blue +const COLOR_WARN = "#FBBF24"; // amber +const COLOR_ERROR = "#F87171"; // soft red +const COLOR_SUCCESS = "#86EFAC"; // mint green +const COLOR_DIM = MUTED; + +/** Sentry banner ASCII rows (kept in sync with `src/lib/banner.ts`). */ +const BANNER_ROWS = [ + " ███████╗███████╗███╗ ██╗████████╗██████╗ ██╗ ██╗", + " ██╔════╝██╔════╝████╗ ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝", + " ███████╗█████╗ ██╔██╗ ██║ ██║ ██████╔╝ ╚████╔╝ ", + " ╚════██║██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗ ╚██╔╝ ", + " ███████║███████╗██║ ╚████║ ██║ ██║ ██║ ██║ ", + " ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ", +]; + +/** Vertical purple gradient applied row-by-row to the banner. */ +const BANNER_GRADIENT = [ + "#B4A4DE", + "#9C84D4", + "#8468C8", + "#6C4EBA", + "#5538A8", + "#432B8A", +]; + +/** Spinner frames; matches `src/lib/init/spinner.ts` cadence. */ const SPINNER_FRAMES = process.platform.startsWith("win") ? ["●", "o", "O", "0"] : ["◒", "◐", "◓", "◑"]; const SPINNER_INTERVAL_MS = process.platform.startsWith("win") ? 80 : 120; -const STOP_ICONS: Record = { - 0: "◆", - 1: "■", - 2: "▲", +/** Glyph + color for each log severity. */ +const LOG_STYLES: Record = { + info: { icon: "●", color: COLOR_INFO }, + warn: { icon: "▲", color: COLOR_WARN }, + error: { icon: "✖", color: COLOR_ERROR }, + success: { icon: "✔", color: COLOR_SUCCESS }, + message: { icon: " ", color: FOREGROUND }, +}; + +/** Spinner stop icons + colors. Stays consistent with the live frames. */ +const STOP_STYLES: Record = { + 0: { icon: "✔", color: COLOR_SUCCESS }, + 1: { icon: "✖", color: COLOR_ERROR }, + 2: { icon: "▲", color: COLOR_WARN }, }; +// ───────────────────────────── Type plumbing ────────────────────────── + /** * OpenTUI Renderable classes used by this module. Resolved once via * dynamic import in `createOpenTuiUI()` so the `@opentui/core` import @@ -115,6 +170,8 @@ export async function createOpenTuiUI(): Promise { return new OpenTuiUI(renderer, mod); } +// ──────────────────────────── Implementation ────────────────────────── + /** * Full-screen WizardUI. See module doc for layout and lifecycle. * @@ -125,9 +182,12 @@ export async function createOpenTuiUI(): Promise { export class OpenTuiUI implements WizardUI { private readonly renderer: CliRenderer; private readonly classes: OpenTuiClasses; - private readonly headerLine: TextRenderableType; + private readonly headerBox: BoxRenderableType; + private readonly headerIntro: TextRenderableType; private readonly logPane: BoxRenderableType; - private readonly spinnerLine: TextRenderableType; + private readonly spinnerWrap: BoxRenderableType; + private readonly spinnerIcon: TextRenderableType; + private readonly spinnerText: TextRenderableType; private readonly promptArea: BoxRenderableType; private spinnerActive = false; private spinnerTimer: ReturnType | undefined; @@ -144,8 +204,7 @@ export class OpenTuiUI implements WizardUI { * Append-only transcript of every log/intro/outro/cancel line. On * dispose we write these to stderr after destroying the renderer * (which restores the main screen) so the user actually sees the - * wizard's output in their scrollback. Without this the alternate- - * screen takeover hides everything the moment `destroy()` returns. + * wizard's output in their scrollback. */ private readonly transcript: string[] = []; @@ -155,24 +214,91 @@ export class OpenTuiUI implements WizardUI { const ctx = renderer.root.ctx; const { BoxRenderable, TextRenderable } = classes; - // Build the four-region column layout. The log pane gets `flexGrow` - // so it consumes any vertical space left over after the fixed-size - // header / spinner / prompt rows. + // Outer chrome — single rounded border around the whole wizard area + // so the alternate-screen takeover feels intentional rather than + // raw text floating on a void. const root = new BoxRenderable(ctx, { flexDirection: "column", flexGrow: 1, + borderStyle: "rounded", + border: true, + borderColor: MUTED, + padding: 1, }); - this.headerLine = new TextRenderable(ctx, { content: "" }); + + // Header: banner (one Text per row, gradient-colored) + an intro line + // that the runner fills in via `intro()`. + this.headerBox = new BoxRenderable(ctx, { + flexDirection: "column", + flexShrink: 0, + }); + for (const [i, row] of BANNER_ROWS.entries()) { + const bannerLine = new TextRenderable(ctx, { + content: row, + fg: BANNER_GRADIENT[i] ?? BANNER_GRADIENT[0], + }); + this.headerBox.add(bannerLine); + } + this.headerIntro = new TextRenderable(ctx, { + content: "", + fg: ACCENT, + marginTop: 1, + }); + this.headerBox.add(this.headerIntro); + + // A muted divider line between the header and the live area below. + // OpenTUI doesn't ship a horizontal-rule renderable, so we settle + // for a thin Box with a top border. + const divider = new BoxRenderable(ctx, { + borderStyle: "single", + border: ["top"], + borderColor: MUTED, + height: 1, + flexShrink: 0, + marginTop: 1, + marginBottom: 1, + }); + + // Log pane: scrolling-feeling area where every appended line lands. + // `flexGrow: 1` lets it absorb leftover vertical space. this.logPane = new BoxRenderable(ctx, { flexDirection: "column", flexGrow: 1, + gap: 0, }); - this.spinnerLine = new TextRenderable(ctx, { content: "" }); - this.promptArea = new BoxRenderable(ctx, { flexDirection: "column" }); - root.add(this.headerLine); + // Spinner row: icon (gets recolored on stop) and message side-by-side + // so the message can word-wrap independently of the icon. + this.spinnerWrap = new BoxRenderable(ctx, { + flexDirection: "row", + flexShrink: 0, + marginTop: 1, + }); + this.spinnerIcon = new TextRenderable(ctx, { + content: "", + fg: ACCENT, + width: 3, + }); + this.spinnerText = new TextRenderable(ctx, { + content: "", + fg: FOREGROUND, + flexGrow: 1, + }); + this.spinnerWrap.add(this.spinnerIcon); + this.spinnerWrap.add(this.spinnerText); + + // Prompt area: prompts mount their own message + Select in here and + // tear it down on resolution. + this.promptArea = new BoxRenderable(ctx, { + flexDirection: "column", + flexShrink: 0, + marginTop: 1, + }); + + root.add(this.headerBox); + root.add(divider); root.add(this.logPane); - root.add(this.spinnerLine); + root.add(this.spinnerWrap); root.add(this.promptArea); renderer.root.add(root); @@ -181,28 +307,34 @@ export class OpenTuiUI implements WizardUI { // ── Lifecycle ───────────────────────────────────────────────────── + banner(_art: string): void { + // No-op — the alternate-screen header already paints the banner + // with the proper gradient. The runner-supplied ANSI string is + // discarded because OpenTUI can't render embedded escape codes. + } + intro(title: string): void { - const rendered = renderInlineMarkdown(title); - this.headerLine.content = rendered; - this.transcript.push(rendered); + const clean = stripAnsi(title); + this.headerIntro.content = `▸ ${clean}`; + this.transcript.push(`▸ ${clean}`); } outro(message: string): void { - this.appendLog(`✓ ${message}`); + this.appendLine("success", message); } cancel(message: string): void { - this.appendLog(`✗ ${message}`); + this.appendLine("error", message); } // ── Logging ─────────────────────────────────────────────────────── log: WizardLog = { - info: (message) => this.appendLog(`info: ${message}`), - warn: (message) => this.appendLog(`warn: ${message}`), - error: (message) => this.appendLog(`error: ${message}`), - success: (message) => this.appendLog(`✓ ${message}`), - message: (message) => this.appendLog(message), + info: (message) => this.appendLine("info", message), + warn: (message) => this.appendLine("warn", message), + error: (message) => this.appendLine("error", message), + success: (message) => this.appendLine("success", message), + message: (message) => this.appendLine("message", message), }; // ── Spinner ─────────────────────────────────────────────────────── @@ -212,7 +344,8 @@ export class OpenTuiUI implements WizardUI { start: (message?: string) => { this.spinnerActive = true; this.spinnerFrame = 0; - this.spinnerMessage = message ?? ""; + this.spinnerMessage = stripAnsi(message ?? ""); + this.spinnerIcon.fg = ACCENT; this.renderSpinnerFrame(); if (!this.spinnerTimer) { this.spinnerTimer = setInterval(() => { @@ -223,7 +356,7 @@ export class OpenTuiUI implements WizardUI { }, message: (message?: string) => { if (this.spinnerActive && message !== undefined) { - this.spinnerMessage = message; + this.spinnerMessage = stripAnsi(message); this.renderSpinnerFrame(); } }, @@ -236,13 +369,15 @@ export class OpenTuiUI implements WizardUI { clearInterval(this.spinnerTimer); this.spinnerTimer = undefined; } - const finalMessage = message ?? this.spinnerMessage; + const finalMessage = message ? stripAnsi(message) : this.spinnerMessage; + // Promote the spinner's final state into the scrollable log so + // it survives the next `start()` call, then clear the live row. if (finalMessage) { - // Emit the final state into the scrollable log so it survives - // subsequent spinner re-uses, then clear the live spinner row. - this.appendLog(`${STOP_ICONS[code]} ${finalMessage}`); + const style = STOP_STYLES[code]; + this.appendStyledLine(style.icon, style.color, finalMessage); } - this.spinnerLine.content = ""; + this.spinnerIcon.content = ""; + this.spinnerText.content = ""; this.spinnerMessage = ""; }, }; @@ -263,8 +398,8 @@ export class OpenTuiUI implements WizardUI { ): Promise { // Multi-select is built on top of `Select` with augmented labels // ("[x] foo" vs "[ ] foo") and custom keypress handling: space - // toggles, enter confirms. This avoids needing a separate - // multi-select renderable, which OpenTUI doesn't ship. + // toggles, enter confirms. OpenTUI doesn't ship a multi-select + // renderable so we do this in userland. return this.runMultiSelectPrompt({ message: opts.message, options: opts.options, @@ -298,9 +433,7 @@ export class OpenTuiUI implements WizardUI { // `destroy()` switches the terminal back from the alternate screen // to the main screen, which wipes everything OpenTUI rendered. // Replay the transcript to stderr so the wizard's intro/log lines - // appear in the user's scrollback after exit. Stderr (rather than - // stdout) keeps human-readable progress out of pipeable wizard - // output for any downstream consumers. + // appear in the user's scrollback after exit. try { this.renderer.destroy(); } catch { @@ -314,21 +447,45 @@ export class OpenTuiUI implements WizardUI { // ── Internal helpers ────────────────────────────────────────────── - private appendLog(text: string): void { - const { TextRenderable } = this.classes; - const rendered = renderInlineMarkdown(text); - const line = new TextRenderable(this.renderer.root.ctx, { - content: rendered, + /** + * Append a single styled log line — a row Box with a colored icon + * cell on the left and the message text on the right. Each line + * also gets pushed onto the transcript (sans color codes — they + * wouldn't survive the scrollback handoff anyway). + */ + private appendLine(severity: keyof WizardLog, message: string): void { + const { icon, color } = LOG_STYLES[severity]; + const clean = stripAnsi(message); + this.appendStyledLine(icon, color, clean); + } + + private appendStyledLine(icon: string, color: string, text: string): void { + const { BoxRenderable, TextRenderable } = this.classes; + const ctx = this.renderer.root.ctx; + const row = new BoxRenderable(ctx, { + flexDirection: "row", + flexShrink: 0, + }); + const iconCell = new TextRenderable(ctx, { + content: icon, + fg: color, + width: 3, + }); + const textCell = new TextRenderable(ctx, { + content: text, + fg: FOREGROUND, + flexGrow: 1, }); - this.logPane.add(line); - this.transcript.push(rendered); + row.add(iconCell); + row.add(textCell); + this.logPane.add(row); + this.transcript.push(`${icon} ${text}`); } private renderSpinnerFrame(): void { const frame = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0] ?? "•"; - this.spinnerLine.content = renderInlineMarkdown( - `${frame} ${this.spinnerMessage}` - ); + this.spinnerIcon.content = frame; + this.spinnerText.content = this.spinnerMessage; } /** @@ -360,21 +517,33 @@ export class OpenTuiUI implements WizardUI { ) : 0; - const wrapper = new BoxRenderable(ctx, { flexDirection: "column" }); + const wrapper = new BoxRenderable(ctx, { + flexDirection: "column", + gap: 1, + }); const messageNode = new TextRenderable(ctx, { - content: renderInlineMarkdown(opts.message), + content: stripAnsi(opts.message), + fg: FOREGROUND, }); const selectNode = new SelectRenderable(ctx, { options: tuiOptions, selectedIndex: initialIndex, height: Math.min(opts.options.length + 1, 8), + textColor: FOREGROUND, + focusedTextColor: FOREGROUND, + selectedBackgroundColor: ACCENT, + selectedTextColor: "#FFFFFF", + descriptionColor: COLOR_DIM, + showScrollIndicator: opts.options.length > 8, + showDescription: true, }); wrapper.add(messageNode); wrapper.add(selectNode); this.promptArea.add(wrapper); - // SelectRenderable extends Renderable which is an EventEmitter. - // `itemSelected` fires when the user presses enter on an option. + // SelectRenderable extends Renderable (an EventEmitter). The + // `itemSelected` event fires when the user presses enter on an + // option. selectNode.focus(); selectNode.on( "itemSelected", @@ -406,29 +575,43 @@ export class OpenTuiUI implements WizardUI { const buildTuiOptions = (): OpenTuiSelectOption[] => opts.options.map((option) => ({ - name: `[${selected.has(option.value) ? "x" : " "}] ${option.label}`, + name: `${selected.has(option.value) ? "◉" : "◯"} ${option.label}`, description: option.hint ?? "", value: option.value, })); - const wrapper = new BoxRenderable(ctx, { flexDirection: "column" }); + const wrapper = new BoxRenderable(ctx, { + flexDirection: "column", + gap: 1, + }); const messageNode = new TextRenderable(ctx, { - content: renderInlineMarkdown( - `${opts.message}\n(space to toggle, enter to confirm)` - ), + content: stripAnsi(opts.message), + fg: FOREGROUND, + }); + const hintNode = new TextRenderable(ctx, { + content: "space toggle · enter confirm · esc cancel", + fg: COLOR_DIM, }); const selectNode = new SelectRenderable(ctx, { options: buildTuiOptions(), height: Math.min(opts.options.length + 2, 10), + textColor: FOREGROUND, + focusedTextColor: FOREGROUND, + selectedBackgroundColor: ACCENT, + selectedTextColor: "#FFFFFF", + descriptionColor: COLOR_DIM, + showScrollIndicator: opts.options.length > 10, + showDescription: true, }); wrapper.add(messageNode); + wrapper.add(hintNode); wrapper.add(selectNode); this.promptArea.add(wrapper); const selectRenderable = selectNode as SelectRenderableType & { getSelectedOption: () => OpenTuiSelectOption | null; // `setOptions` is how SelectRenderable updates its visible options - // — used here to redraw the [x]/[ ] markers when the user toggles. + // — used here to redraw the marker glyph when the user toggles. setOptions?: (options: OpenTuiSelectOption[]) => void; }; selectRenderable.focus(); diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 796100ac0..abdab2226 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -121,6 +121,15 @@ export type ConfirmOptions = { export type WizardUI = AsyncDisposable & { // ── Lifecycle messages ──────────────────────────────────────────── + /** + * Display the multi-line ASCII banner. Implementations decide where + * the banner appears: `OpenTuiUI` paints it inside its alternate- + * screen header (the call may be a no-op if the header already shows + * it), while `LoggingUI` writes the pre-styled ANSI string to stderr. + * Always called once, before `intro()`. + */ + banner(art: string): void; + /** Display the wizard intro banner / heading. */ intro(title: string): void; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 0205bdde2..ca88d258b 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -333,10 +333,15 @@ async function preamble( ); } - // Push the banner through the UI so OpenTuiUI's alternate-screen - // buffer captures it. Writing to stderr directly would land on the - // main screen and be hidden by OpenTUI's screen takeover. - ui.log.message(formatBanner()); + // Banner rendering is delegated to the UI implementation: + // - `OpenTuiUI` paints the banner inside its alternate-screen + // header, gradient-colored row by row, and treats `banner()` as + // a no-op (the layout already includes it). + // - `LoggingUI` writes a plain ANSI version to stderr. + // Calling it on `ui` directly avoids the previous bug where a raw + // `process.stderr.write` was hidden behind OpenTUI's alternate- + // screen takeover. + ui.banner(formatBanner()); ui.intro("sentry init"); let confirmed: boolean; diff --git a/test/lib/init/ui/mock-ui.ts b/test/lib/init/ui/mock-ui.ts index aebfdbd67..41ae322cf 100644 --- a/test/lib/init/ui/mock-ui.ts +++ b/test/lib/init/ui/mock-ui.ts @@ -24,6 +24,7 @@ import { } from "../../../../src/lib/init/ui/types.js"; export type MockCall = + | { kind: "banner"; art: string } | { kind: "intro"; title: string } | { kind: "outro"; message: string } | { kind: "cancel"; message: string } @@ -105,6 +106,7 @@ export function createMockUI(): { } const ui: WizardUI = { + banner: (art) => calls.push({ kind: "banner", art }), intro: (title) => calls.push({ kind: "intro", title }), outro: (message) => calls.push({ kind: "outro", message }), cancel: (message) => calls.push({ kind: "cancel", message }), From d6c540f880702f6d626390ffcda2408d3ad7b89c Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:16:07 +0000 Subject: [PATCH 08/20] feat(init): rewrite OpenTuiUI in React with sidebar tips and structured summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes triggered the rewrite: 1. **Multiselect toggle was broken.** The imperative version called `SelectRenderable.setOptions()` from inside a global keypress handler. The renderable's internal `selectedIndex` was mutable state read on each space-press, and reading it could lag the visible highlight by one frame on fast keyboards — toggles landed on the wrong row or were silently dropped. 2. **No place to surface Sentry product facts.** The user asked for a panel that helps onboarding users learn what they get out of Sentry beyond the wizard itself. 3. **The completion summary leaked markdown.** `formatResult` built terminal-flavored markdown (color tags, an aligned KV table, a tree of changed files) and pushed it through `ui.log.message`. `OpenTuiUI`'s TextRenderable can't parse markdown — it strips ANSI, leaving literal `~` tags and pipe-cells in the visible output. ## Architecture The OpenTuiUI class is now a thin imperative bridge that mutates a `WizardStore` (`src/lib/init/ui/opentui-store.ts`). The store is a minimal external store with the React 18+ `useSyncExternalStore` contract — listeners are notified on every snapshot replacement. The React tree (`src/lib/init/ui/opentui-app.tsx`) subscribes via `useSyncExternalStore` and renders the layout declaratively: ┌─ Sentry init ──────────────────────────────────────────────┐ │ banner (gradient, 6 rows) │ Did you know? │ │ ▸ sentry init │ │ │ ───────── │ │ │ ● log line │ │ │ ▲ log line │ Tip 3 of 12 │ │ ◒ spinner... │ │ │ Summary panel (after completion) │ │ │ Prompt area (transient) │ │ └────────────────────────────────────────────────────────────┘ The MultiSelectPrompt component owns its own selected-set state via `useState` and uses `useKeyboard` (from `@opentui/react`) for space/enter handling. React's render cycle guarantees the `[◉]` / `[◯]` markers always reflect the current toggle state. The Sidebar rotates through 12 curated tips (`sentry-tips.ts`) covering errors↔traces, replay, tracing, alerts, releases, source maps, crons, user feedback, profiling, AI monitoring, Seer, and self-hosted. The OpenTuiUI bridge ticks the rotation index every 8s. ## New `WizardUI.summary()` method Added `summary(WizardSummary)` to the WizardUI interface for the completion panel. `WizardSummary` is structured data (`{ fields: [{label, value}], changedFiles?: [{action, path}] }`) not pre-rendered markdown. - `OpenTuiUI` mounts the new `SummaryPanel` React component: a top-bordered box with right-aligned label cells and a flat changed-files list (one per row, colored `+`/`~`/`−` glyph). - `LoggingUI.summary()` writes the same data as a compact two-column listing to stdout, matching the rest of the non-interactive output style. `formatters.ts` now builds the structured summary and calls `ui.summary()` instead of pushing markdown through `ui.log.message`. ## Imports / build - Added `react@^19`, `@opentui/react@^0.2`, `@types/react` as devDependencies (bundled into the Bun binary, externalized from the npm/Node distribution). - `tsconfig.json` enables `jsx: "react-jsx"` with `jsxImportSource: "@opentui/react"` so JSX intrinsics (``, ``, ` { + if (option) { + prompt.resolve(String(option.value)); + } + }} + options={prompt.options.map((option) => ({ + name: option.label, + description: option.hint ?? "", + value: option.value, + }))} + selectedBackgroundColor={ACCENT} + selectedIndex={prompt.initialIndex} + selectedTextColor="#FFFFFF" + showDescription + showScrollIndicator={prompt.options.length > 8} + textColor={FOREGROUND} + /> + + ); +} + +/** + * Multi-select uses local state to track the toggled values plus the + * currently-highlighted row. On every keystroke `useKeyboard` runs: + * - space → flip the highlighted option in the selection set + * - enter → commit the current selection + * + * Tracking the highlighted index manually (rather than asking the + * SelectRenderable for `getSelectedOption()`) avoids a race the + * imperative version had: the renderable's `selectedIndex` was + * internal mutable state and reading it on space-press could lag the + * visible highlight by one frame on fast keyboards. + */ +function MultiSelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + const [selected, setSelected] = useState>( + () => new Set(prompt.initialSelected) + ); + const [highlighted, setHighlighted] = useState(0); + + const decoratedOptions = prompt.options.map((option) => ({ + name: `${selected.has(option.value) ? "◉" : "◯"} ${option.label}`, + description: option.hint ?? "", + value: option.value, + })); + + useKeyboard((event) => { + if (event.name === "space") { + 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; + }); + } else if (event.name === "return" || event.name === "enter") { + if (prompt.required && selected.size === 0) { + return; + } + // Preserve the 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 + 0) { lines.push(""); @@ -419,14 +441,15 @@ export class OpenTuiUI implements WizardUI { ...summary.fields.map((field) => field.label.length) ); for (const field of summary.fields) { - lines.push(` ${field.label.padEnd(labelWidth)} ${field.value}`); + const label = chalk.hex(REPORT_MUTED)(field.label.padEnd(labelWidth)); + lines.push(` ${label} ${field.value}`); } } if (summary?.changedFiles && summary.changedFiles.length > 0) { lines.push(""); - lines.push(" Changed files"); + lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); for (const file of summary.changedFiles) { - lines.push(` ${changedFileGlyph(file.action)} ${file.path}`); + lines.push(` ${changedFileGlyphColored(file.action)} ${file.path}`); } } return lines.join("\n"); @@ -472,12 +495,19 @@ export class OpenTuiUI implements WizardUI { } } -function changedFileGlyph(action: string): string { +/** + * 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. + */ +function changedFileGlyphColored(action: string): string { if (action === "create") { - return "+"; + return chalk.hex(REPORT_SUCCESS)("+"); } if (action === "delete") { - return "−"; + return chalk.hex(REPORT_ERROR)("−"); } - return "~"; + return chalk.hex(REPORT_WARN)("~"); } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ca88d258b..68ffc2548 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -313,9 +313,15 @@ async function confirmExperimental( if (yes) { return true; } + // The wizard modifies files on disk. Keep the prompt short — the + // tone used to be "EXPERIMENTAL: …" in all caps, which felt + // alarming. The friendlier wording still telegraphs that the + // wizard will edit code, and gives an obvious abort path before + // anything happens. const proceed = await ui.confirm({ message: - "EXPERIMENTAL: This feature is experimental and may modify your code. Continue?", + "Ready to set up Sentry? The wizard will edit files in this directory.", + initialValue: true, }); return Boolean(abortIfCancelled(proceed)); } From b252bb1f783b4149a2a141ae3339a584ec4d3018 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:52:22 +0000 Subject: [PATCH 13/20] feat(init): explicit Yes/No experimental prompt with muted hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After comparing the new flow against the original `@clack/prompts` flow, the only behavior gap was the experimental confirm — and the gap was UX, not function. The previous `ui.confirm` displayed a single yes/no question; "no" was technically right but ambiguous about what would happen next. Switch to `ui.select<"continue" | "exit">` so each branch carries an explicit, muted hint: ▶ Yes, continue wizard will detect your stack and apply changes No, exit exits without making any changes This makes the cancel path obvious without relying on tone in the question itself. The post-dispose report still shows `✖ Setup cancelled.` (red) when the user picks "No, exit". Two related fixes: 1. **Select/multiselect height arithmetic.** OpenTUI's `SelectRenderable` allocates 2 rows per option when `showDescription` is on (label + hint), 1 row otherwise. The previous `Math.min(prompt.options.length + 1, 8)` only counted the label rows, so options with hints clipped behind the scroll. Detect whether any option has a hint, set `linesPerItem = hasDescriptions ? 2 : 1`, and size the renderable to `visibleItems * linesPerItem`. 2. **Conditional `showDescription`.** When no option carries a hint we now pass `showDescription={false}`, which gives plain single-line rows for confirmation-style prompts (e.g. the team ambiguity prompt). Previously every Select reserved row space for an empty description. Beyond the experimental prompt, comparing the old flow line by line confirmed: - Banner — old went straight to stderr, new goes through `ui.banner()` which is a no-op on OpenTuiUI (header paints it directly) and writes to stderr on LoggingUI. Parity preserved. - Intro / outro — old used `clack.intro`/`outro` framing, new uses the box title + a green `✔` outro line. - All log severities (info / warn / error / success / message) are routed through `ui.log.*` and rendered with the same glyphs the old clack flow used (●, ▲, ✖, ✔). - Cancel paths from preflight, git checks, and prompt cancellations all hit `ui.cancel` → red `✖` line in the post-dispose report. - Dry-run warning, AI disclaimer, feedback prompt, docs link — all preserved as live log lines (intentionally omitted from the post-dispose scrollback report to keep the success summary compact, per earlier feedback). Lint, typecheck, 6248/6248 unit tests, and check:deps all clean. --- src/lib/init/ui/opentui-app.tsx | 31 +++++++++++++++++++++++++------ src/lib/init/wizard-runner.ts | 33 ++++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/lib/init/ui/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx index 62d9c4160..dd1684193 100644 --- a/src/lib/init/ui/opentui-app.tsx +++ b/src/lib/init/ui/opentui-app.tsx @@ -335,6 +335,16 @@ function SelectPrompt({ }: { 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} @@ -342,7 +352,7 @@ function SelectPrompt({ descriptionColor={MUTED} focused focusedTextColor={FOREGROUND} - height={Math.min(prompt.options.length + 1, 8)} + height={visibleItems * linesPerItem} onSelect={(_index, option) => { if (option) { prompt.resolve(String(option.value)); @@ -356,8 +366,8 @@ function SelectPrompt({ selectedBackgroundColor={ACCENT} selectedIndex={prompt.initialIndex} selectedTextColor="#FFFFFF" - showDescription - showScrollIndicator={prompt.options.length > 8} + showDescription={hasDescriptions} + showScrollIndicator={prompt.options.length > maxVisibleItems} textColor={FOREGROUND} /> @@ -429,6 +439,15 @@ function MultiSelectPrompt({ } }); + // Same height arithmetic as SelectPrompt — see comment there. The + // multiselect cap is slightly higher (10 vs 8 visible items) + // because feature lists tend to be longer than disambiguation + // selects. + const hasDescriptions = prompt.options.some((option) => option.hint); + const linesPerItem = hasDescriptions ? 2 : 1; + const maxVisibleItems = 10; + const visibleItems = Math.min(prompt.options.length, maxVisibleItems); + return ( {prompt.message} @@ -442,13 +461,13 @@ function MultiSelectPrompt({ descriptionColor={MUTED} focused focusedTextColor={FOREGROUND} - height={Math.min(prompt.options.length + 2, 10)} + height={visibleItems * linesPerItem} onChange={(index) => setHighlighted(index)} options={decoratedOptions} selectedBackgroundColor={ACCENT} selectedTextColor="#FFFFFF" - showDescription - showScrollIndicator={prompt.options.length > 10} + showDescription={hasDescriptions} + showScrollIndicator={prompt.options.length > maxVisibleItems} textColor={FOREGROUND} /> diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 68ffc2548..7686d5b92 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -313,17 +313,32 @@ async function confirmExperimental( if (yes) { return true; } - // The wizard modifies files on disk. Keep the prompt short — the - // tone used to be "EXPERIMENTAL: …" in all caps, which felt - // alarming. The friendlier wording still telegraphs that the - // wizard will edit code, and gives an obvious abort path before - // anything happens. - const proceed = await ui.confirm({ + // The wizard modifies files on disk. We use `select` rather than + // `confirm` so the cancel path can carry a muted, explicit hint + // ("exits without changes") — the previous binary yes/no felt + // ambiguous about what "no" did. The earlier wording used an + // all-caps "EXPERIMENTAL:" prefix which read like a warning the + // user had to dismiss; this version frames the question as a + // sanity check before the wizard does work. + const choice = await ui.select<"continue" | "exit">({ message: - "Ready to set up Sentry? The wizard will edit files in this directory.", - initialValue: true, + "This is experimental and will modify files in this directory. Continue?", + options: [ + { + value: "continue", + label: "Yes, continue", + hint: "wizard will detect your stack and apply changes", + }, + { + value: "exit", + label: "No, exit", + hint: "exits without making any changes", + }, + ], + initialValue: "continue", }); - return Boolean(abortIfCancelled(proceed)); + const resolved = abortIfCancelled(choice); + return resolved === "continue"; } async function preamble( From 0635f524d947c6a0b5d10938f3e2b96d24f05036 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:06:59 +0000 Subject: [PATCH 14/20] feat(init): tree view for changed files + persistent 'Files analyzed' panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements that came from comparing the new TUI flow to the behavior the old clack flow had. ## Tree view for changed files The original `@clack/prompts` formatter rendered changed files as a nested directory tree (`├─ src/`, `│ ├─ app/`, …). The first React iteration flattened it to a one-line-per-file list, which worked but lost the visual grouping that made big patches readable at a glance. New `src/lib/init/ui/file-tree.ts` exposes: - `buildFileTree(files)` — collapses common prefixes; sorts dirs before files within each level (alphabetical thereafter). - `flattenTree(root)` — emits one `FileTreeRow` per visible line with the box-drawing prefix already computed. Both `OpenTuiUI` (live React panel + post-dispose stderr report) and `LoggingUI` (CI stdout summary) consume the same tree shape and color it according to their renderer: Changed files ├─ src/ │ ├─ app/ │ │ ├─ + instrumentation-client.ts │ │ └─ ~ layout.tsx │ ├─ ~ router.tsx │ └─ + server.ts └─ ~ vite.config.ts In the React panel and the chalk-colored stderr report: - Box-drawing branches in muted gray - `+` create in green, `~` modify in yellow, `−` delete in red - File / directory labels in foreground `LoggingUI` ships the same tree shape as plain ASCII so CI logs keep the structure without ANSI escapes. ## 'Files analyzed' sidebar panel Old flow: every `read-files` tool call updated the spinner with a multi-line "Reading files…" tree, then the next tool overwrote it within ~half a second. Users couldn't tell what context the AI had looked at. New flow: a persistent `` in the right sidebar that accumulates every read across the entire session. Each row shows a status icon — yellow `●` while reading, green `✔` once analyzed — plus the file basename. A counter at the top (`3/5 read`) gives a quick health-check; a `+ N more` line hides anything beyond the visible 10 rows so the panel doesn't push the tips off-screen. Plumbing: - New optional `recordFilesReading(paths)` and `markFilesAnalyzed(paths)` methods on `WizardUI`. Optional so `LoggingUI` can leave them undefined (the spinner-message approach there already lands as separate log lines and works fine in non-interactive contexts). - `OpenTuiUI` implements them by mutating the store; React's `useSyncExternalStore` re-renders the panel. - `wizard-runner.ts` calls them around `executeTool()` for `read-files` operations only — list-dir / file-exists-batch pass through unchanged. - The store dedupes by path: re-reading the same file in a later batch keeps the entry but doesn't downgrade an `analyzed` status back to `reading`. The sidebar still hides on terminals narrower than 100 columns (per `SIDEBAR_BREAKPOINT`); on those the read-files tree still flashes in the spinner the same way it did before. Lint, typecheck, 6248/6248 unit tests, check:deps all clean. --- src/lib/init/ui/file-tree.ts | 165 +++++++++++++++++++++++++++++++ src/lib/init/ui/logging-ui.ts | 31 +++++- src/lib/init/ui/opentui-app.tsx | 160 ++++++++++++++++++++++++++---- src/lib/init/ui/opentui-store.ts | 65 ++++++++++++ src/lib/init/ui/opentui-ui.ts | 34 ++++++- src/lib/init/ui/types.ts | 16 +++ src/lib/init/wizard-runner.ts | 14 +++ 7 files changed, 459 insertions(+), 26 deletions(-) create mode 100644 src/lib/init/ui/file-tree.ts diff --git a/src/lib/init/ui/file-tree.ts b/src/lib/init/ui/file-tree.ts new file mode 100644 index 000000000..9904523b6 --- /dev/null +++ b/src/lib/init/ui/file-tree.ts @@ -0,0 +1,165 @@ +/** + * 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. + * + * 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. + */ + +export type ChangedFile = { + action: string; + path: string; +}; + +export type FileTreeNode = { + /** Path segment for this node (e.g. "src", "router.tsx"). */ + name: string; + /** + * Full file path relative to the project root. Set only on leaf + * (file) nodes. Directory nodes leave this `undefined`. + */ + path?: string; + /** Action recorded by the workflow — only on leaf nodes. */ + action?: string; + children: FileTreeNode[]; +}; + +/** + * Flat row produced by `flattenTree()` — one per visible line in the + * rendered output. Carries everything a renderer needs to draw a + * single row without re-walking the tree. + */ +export type FileTreeRow = { + /** Box-drawing prefix for ancestor pipes (e.g. `"│ │ "`). */ + prefix: string; + /** Branch glyph for this row — `"├─"` or `"└─"`. */ + branch: string; + /** + * `"file"` if this row represents a leaf (with action + path); + * `"directory"` otherwise. Renderers use this to decide whether to + * draw the action glyph cell. + */ + kind: "file" | "directory"; + /** Display name. Directories get a trailing `/`. */ + label: string; + /** Full path — only set on `file` rows. */ + path?: string; + /** Action — only set on `file` rows. */ + action?: string; +}; + +function splitPath(filePath: string): string[] { + return filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); +} + +/** + * Build a directory tree from the flat changed-files list. Files + * sharing a common prefix collapse into nested directories. + */ +export function buildFileTree(files: ChangedFile[]): FileTreeNode { + const root: FileTreeNode = { name: "", children: [] }; + + // Maintain a parallel map keyed by parent reference so we can do + // O(1) lookups for "does this directory already have a child named + // X?" without scanning each parent's children array. + const childIndex = new WeakMap>(); + childIndex.set(root, new Map()); + + for (const file of files) { + const parts = splitPath(file.path); + let current = root; + + for (const [index, part] of parts.entries()) { + const map = childIndex.get(current) ?? new Map(); + let child = map.get(part); + if (!child) { + child = { name: part, children: [] }; + map.set(part, child); + childIndex.set(current, map); + childIndex.set(child, new Map()); + current.children.push(child); + } + + if (index === parts.length - 1) { + child.path = file.path; + child.action = file.action; + } + + current = child; + } + } + + sortRecursive(root); + return root; +} + +/** + * Sort the tree in place: directories before files at each level, + * then alphabetical within each group. Matches the legacy formatter's + * ordering so existing screenshots/snapshots stay valid. + */ +function sortRecursive(node: FileTreeNode): void { + node.children.sort((left, right) => { + const leftIsDir = left.children.length > 0 && !left.action; + const rightIsDir = right.children.length > 0 && !right.action; + if (leftIsDir !== rightIsDir) { + return leftIsDir ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + for (const child of node.children) { + sortRecursive(child); + } +} + +/** + * Walk the tree and emit one {@link FileTreeRow} per line, ready to + * be fed into a renderer. Directory nodes appear before their + * children with the appropriate box-drawing prefix. + */ +export function flattenTree(root: FileTreeNode): FileTreeRow[] { + const rows: FileTreeRow[] = []; + walk(root.children, "", rows); + return rows; +} + +function walk( + nodes: FileTreeNode[], + prefix: string, + rows: FileTreeRow[] +): void { + for (const [index, node] of nodes.entries()) { + const isLast = index === nodes.length - 1; + rows.push(rowFor(node, prefix, isLast)); + if (node.children.length > 0) { + const childPrefix = `${prefix}${isLast ? " " : "│ "}`; + walk(node.children, childPrefix, rows); + } + } +} + +function rowFor( + node: FileTreeNode, + prefix: string, + isLast: boolean +): FileTreeRow { + const isFile = Boolean(node.action); + return { + prefix, + branch: isLast ? "└─" : "├─", + kind: isFile ? "file" : "directory", + label: isFile ? node.name : `${node.name}/`, + ...(node.path !== undefined ? { path: node.path } : {}), + ...(node.action !== undefined ? { action: node.action } : {}), + }; +} diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index 3d8274e86..4b4a93704 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -22,6 +22,7 @@ import { renderInlineMarkdown, renderMarkdown, } from "../../formatters/markdown.js"; +import { buildFileTree, flattenTree } from "./file-tree.js"; import type { ConfirmOptions, MultiSelectOptions, @@ -110,11 +111,11 @@ export class LoggingUI implements WizardUI { if (summary.changedFiles && summary.changedFiles.length > 0) { this.writeLine(this.stdout, ""); this.writeLine(this.stdout, " Changed files:"); - for (const file of summary.changedFiles) { - this.writeLine( - this.stdout, - ` ${changedFileGlyph(file.action)} ${file.path}` - ); + // Render as a directory tree so collapsed common prefixes match + // what the OpenTuiUI panel + post-dispose stderr report show. + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); } } } @@ -222,6 +223,26 @@ function changedFileGlyph(action: string): string { return "~"; } +/** + * Render a single `FileTreeRow` for the LoggingUI's stdout summary. + * No colors — same shape as the OpenTuiUI / post-dispose tree, but + * box-drawing characters and glyphs ship as plain text so CI logs + * stay greppable. + */ +function formatTreeRowPlain(row: { + prefix: string; + branch: string; + kind: "file" | "directory"; + label: string; + action?: string; +}): string { + const branchPart = `${row.prefix}${row.branch}`; + if (row.kind === "directory") { + return `${branchPart} ${row.label}`; + } + return `${branchPart} ${changedFileGlyph(row.action ?? "modify")} ${row.label}`; +} + function stopPrefix(code: SpinnerExitCode): string { switch (code) { case 0: diff --git a/src/lib/init/ui/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx index dd1684193..9f92a3db0 100644 --- a/src/lib/init/ui/opentui-app.tsx +++ b/src/lib/init/ui/opentui-app.tsx @@ -30,10 +30,13 @@ * 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, type FileTreeRow, flattenTree } from "./file-tree.js"; import type { ActivePrompt, + FileReadEntry, LogEntry, LogSeverity, SpinnerState, @@ -127,7 +130,12 @@ export function App({ store }: AppProps): React.ReactNode { spinner={snapshot.spinner} summary={snapshot.summary} /> - {showSidebar ? : null} + {showSidebar ? ( + + ) : null} ); @@ -277,31 +285,55 @@ function SummaryPanel({ ) : null} {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( - - Changed files - {summary.changedFiles.map((file) => ( - - ))} - + ) : null} ); } -function ChangedFileRow({ - file, +/** + * 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, }: { - file: { action: string; path: string }; + files: { action: string; path: string }[]; }): React.ReactNode { - const { glyph, color } = changedFileStyle(file.action); + 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 ( - - {glyph} - - - {file.path} - + {`${row.prefix}${row.branch} `} + {`${glyph} `} + {row.label} ); } @@ -476,7 +508,97 @@ function MultiSelectPrompt({ // ────────────────────────────── Sidebar ─────────────────────────────── -function Sidebar({ tipIndex }: { tipIndex: number }): React.ReactNode { +function Sidebar({ + filesRead, + tipIndex, +}: { + filesRead: FileReadEntry[]; + tipIndex: number; +}): React.ReactNode { + return ( + + + + + ); +} + +/** + * Maximum number of file rows the sidebar shows. Anything beyond this + * collapses into a `+ N more` line so the panel doesn't push the + * tips off-screen on shorter terminals. + */ +const FILES_PANEL_VISIBLE_ROWS = 10; + +/** + * Persistent "Files analyzed" panel — shows every file the wizard has + * read from disk during the session, with a status icon (yellow ● + * while reading, green ✔ once analyzed). Replaces the previous + * spinner-message approach where each batch flashed for half a second. + * + * The panel is suppressed entirely when no reads have been recorded + * yet (so it doesn't draw an empty box at startup). + */ +function FilesAnalyzedPanel({ + filesRead, +}: { + filesRead: FileReadEntry[]; +}): React.ReactNode { + if (filesRead.length === 0) { + return null; + } + const visible = filesRead.slice(-FILES_PANEL_VISIBLE_ROWS); + const overflow = filesRead.length - visible.length; + const analyzed = filesRead.filter( + (entry) => entry.status === "analyzed" + ).length; + return ( + + + {analyzed}/{filesRead.length} read + + + {visible.map((entry) => ( + + ))} + + {overflow > 0 ? ( + + + {overflow} more + + ) : null} + + ); +} + +function FileReadRow({ entry }: { entry: FileReadEntry }): React.ReactNode { + const isAnalyzed = entry.status === "analyzed"; + const glyph = isAnalyzed ? "✔" : "●"; + const color = isAnalyzed ? COLOR_SUCCESS : COLOR_WARN; + // Show the basename for compactness — full paths blow past the + // sidebar's 36-col width regularly. The tooltip-equivalent (full + // path) is unavailable in OpenTUI, so leave the narrow display. + return ( + + + {glyph} + + + {basename(entry.path)} + + + ); +} + +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; @@ -485,12 +607,12 @@ function Sidebar({ tipIndex }: { tipIndex: number }): React.ReactNode { borderColor={MUTED} borderStyle="rounded" flexDirection="column" + flexGrow={1} flexShrink={0} gap={1} padding={1} title=" Did you know? " titleAlignment="left" - width={SIDEBAR_WIDTH} > {tip.title} {tip.body} diff --git a/src/lib/init/ui/opentui-store.ts b/src/lib/init/ui/opentui-store.ts index a65eeec24..b83d89940 100644 --- a/src/lib/init/ui/opentui-store.ts +++ b/src/lib/init/ui/opentui-store.ts @@ -34,6 +34,17 @@ export type SpinnerState = { message: string; }; +/** + * One entry in the persistent "Files analyzed" panel — every file the + * wizard has read from disk during the session. Status transitions + * `reading` → `analyzed` once the tool returns. Renderers paint the + * matching icon. + */ +export type FileReadEntry = { + path: string; + status: "reading" | "analyzed"; +}; + /** Generic option shape passed to mounted prompts. */ export type PromptOption = { value: string; @@ -75,6 +86,15 @@ export type WizardSnapshot = { tipIndex: number; /** Final structured summary, rendered after the workflow completes. */ summary: WizardSummary | null; + /** + * Persistent list of every file the wizard has read from disk. Each + * entry carries a status that transitions `reading` → `analyzed` as + * the workflow progresses. Used by the live "Files analyzed" panel + * 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. + */ + filesRead: FileReadEntry[]; }; export type Listener = () => void; @@ -96,6 +116,7 @@ export class WizardStore { prompt: initial.prompt ?? null, tipIndex: initial.tipIndex ?? 0, summary: initial.summary ?? null, + filesRead: initial.filesRead ?? [], }; } @@ -171,6 +192,50 @@ export class WizardStore { this.update({ summary }); } + /** + * Record that the wizard is currently reading a batch of files. + * Existing entries (read in earlier batches) keep their status so + * the "Files analyzed" panel preserves history; new entries land + * with status `reading` and flip to `analyzed` via + * `markFilesAnalyzed()` when the tool returns. + */ + recordFilesReading(paths: string[]): void { + if (paths.length === 0) { + return; + } + const byPath = new Map( + this.snapshot.filesRead.map((entry) => [entry.path, entry]) + ); + for (const path of paths) { + const existing = byPath.get(path); + // Don't downgrade an already-analyzed entry back to `reading` + // if the same file is read again later in the run. + if (!existing || existing.status === "reading") { + byPath.set(path, { path, status: "reading" }); + } + } + this.update({ filesRead: [...byPath.values()] }); + } + + /** + * Flip the matching entries in `filesRead` from `reading` to + * `analyzed`. Paths not present in the store are added as + * pre-analyzed (defensive — covers tools that return file lists + * without a prior `recordFilesReading` call). + */ + markFilesAnalyzed(paths: string[]): void { + if (paths.length === 0) { + return; + } + const byPath = new Map( + this.snapshot.filesRead.map((entry) => [entry.path, entry]) + ); + for (const path of paths) { + byPath.set(path, { path, status: "analyzed" }); + } + this.update({ filesRead: [...byPath.values()] }); + } + // ── Internal ────────────────────────────────────────────────────── /** diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index 42806f436..e9f75f3e4 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -34,6 +34,7 @@ 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"; @@ -242,6 +243,14 @@ export class OpenTuiUI implements WizardUI { this.store.setSummary(summary); } + recordFilesReading(paths: string[]): void { + this.store.recordFilesReading(paths); + } + + markFilesAnalyzed(paths: string[]): void { + this.store.markFilesAnalyzed(paths); + } + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog = { @@ -448,8 +457,9 @@ export class OpenTuiUI implements WizardUI { if (summary?.changedFiles && summary.changedFiles.length > 0) { lines.push(""); lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); - for (const file of summary.changedFiles) { - lines.push(` ${changedFileGlyphColored(file.action)} ${file.path}`); + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + lines.push(formatTreeRowChalk(row)); } } return lines.join("\n"); @@ -511,3 +521,23 @@ function changedFileGlyphColored(action: string): string { } return chalk.hex(REPORT_WARN)("~"); } + +/** + * Render a single `FileTreeRow` for the post-dispose stderr report. + * Directories show only the box-drawing branch + label; files add + * the action glyph (colored). + */ +function formatTreeRowChalk(row: { + prefix: string; + branch: string; + kind: "file" | "directory"; + label: string; + action?: string; +}): string { + const branch = chalk.hex(REPORT_MUTED)(`${row.prefix}${row.branch}`); + if (row.kind === "directory") { + return ` ${branch} ${row.label}`; + } + const glyph = changedFileGlyphColored(row.action ?? "modify"); + return ` ${branch} ${glyph} ${row.label}`; +} diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index ca3a9619b..3c3fe18f0 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -171,6 +171,22 @@ export type WizardUI = AsyncDisposable & { */ cancel(message: string): void; + /** + * Notify the UI that the wizard is reading the listed files from + * disk. Optional — implementations that don't track reads (e.g. + * `LoggingUI`) leave this undefined. `OpenTuiUI` uses it to populate + * a persistent "Files analyzed" panel so the user can see what + * context the AI looked at, instead of losing it in a half-second + * spinner flash. + */ + recordFilesReading?(paths: string[]): void; + + /** + * Notify the UI that the previously-recorded files have finished + * being analyzed. Same optional contract as `recordFilesReading`. + */ + markFilesAnalyzed?(paths: string[]): void; + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 7686d5b92..708b50d10 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -194,6 +194,16 @@ async function handleSuspendedStep( : describeTool(payload)); spin.message(renderInlineMarkdown(truncateForTerminal(message))); + // Persistent "Files analyzed" panel (OpenTuiUI only — `LoggingUI` + // leaves these methods undefined). The previous flow showed a + // half-second tree of files in the spinner before the next tool + // overwrote it; users couldn't see what context the wizard + // looked at. We feed the read paths into the panel before the + // tool runs, then mark them analyzed afterwards. + if (payload.operation === "read-files") { + ui.recordFilesReading?.(payload.params.paths); + } + const toolResult = await executeTool(payload, context); if (toolResult.message) { @@ -209,6 +219,10 @@ async function handleSuspendedStep( } } + if (payload.operation === "read-files" && toolResult.ok !== false) { + ui.markFilesAnalyzed?.(payload.params.paths); + } + const history = stepHistory.get(stepId) ?? []; history.push(toolResult); stepHistory.set(stepId, history); From bba4c637bdd4468bfedcb83e420e6ec0ac7cfd47 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:37:48 +0000 Subject: [PATCH 15/20] feat(init): replace 'Files analyzed' sidebar panel with inline status line The bordered 'Files analyzed' panel reserved up to 13 sidebar rows with flexShrink={0}, which pushed the 'Did you know?' tip card off-screen on shorter terminals. Hoist file-read activity into a single-line indicator above the spinner instead, freeing the entire sidebar height for tips. Inspired by PostHog's wizard, which avoids unbounded per-file lists in favor of bounded status indicators. --- src/lib/init/ui/opentui-app.tsx | 178 +++++++++++++++---------------- src/lib/init/ui/opentui-store.ts | 19 ++-- src/lib/init/ui/types.ts | 8 +- src/lib/init/wizard-runner.ts | 6 +- 4 files changed, 102 insertions(+), 109 deletions(-) diff --git a/src/lib/init/ui/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx index 9f92a3db0..dc35fb75e 100644 --- a/src/lib/init/ui/opentui-app.tsx +++ b/src/lib/init/ui/opentui-app.tsx @@ -12,15 +12,19 @@ * ┌─ Sentry init ──────────────────────────────────────────────────┐ * │ ╔═══════════════════════════╗ ╔══════════════════════════╗ │ * │ ║ banner ║ ║ Did you know? ║ │ - * │ ║ ▸ sentry init ║ ║ ────────────── ║ │ - * │ ║ ────────── ║ ║ ║ │ - * │ ║ ● log line ║ ║ ║ │ - * │ ║ ▲ log line ║ ║ ║ │ + * │ ║ ────────── ║ ║ ────────────── ║ │ + * │ ║ ● 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 @@ -125,17 +129,13 @@ export function App({ store }: AppProps): React.ReactNode { - {showSidebar ? ( - - ) : null} + {showSidebar ? : null} ); @@ -145,6 +145,7 @@ export function App({ store }: AppProps): React.ReactNode { type MainColumnProps = { bannerRows: { content: string; color: string }[]; + filesRead: FileReadEntry[]; logs: LogEntry[]; spinner: SpinnerState; prompt: ActivePrompt | null; @@ -153,11 +154,16 @@ type MainColumnProps = { function MainColumn({ bannerRows, + filesRead, logs, spinner, prompt, summary, }: 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 = !summary && filesRead.length > 0; return (
@@ -167,6 +173,7 @@ function MainColumn({ ))} + {showFileStatus ? : null} {spinner.active ? : null} {summary ? : null} {prompt ? : null} @@ -242,6 +249,67 @@ function SpinnerRow({ state }: { state: SpinnerState }): React.ReactNode { ); } +/** + * 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 ─────────────────────────────── /** @@ -508,92 +576,16 @@ function MultiSelectPrompt({ // ────────────────────────────── Sidebar ─────────────────────────────── -function Sidebar({ - filesRead, - tipIndex, -}: { - filesRead: FileReadEntry[]; - tipIndex: number; -}): React.ReactNode { - return ( - - - - - ); -} - /** - * Maximum number of file rows the sidebar shows. Anything beyond this - * collapses into a `+ N more` line so the panel doesn't push the - * tips off-screen on shorter terminals. - */ -const FILES_PANEL_VISIBLE_ROWS = 10; - -/** - * Persistent "Files analyzed" panel — shows every file the wizard has - * read from disk during the session, with a status icon (yellow ● - * while reading, green ✔ once analyzed). Replaces the previous - * spinner-message approach where each batch flashed for half a second. - * - * The panel is suppressed entirely when no reads have been recorded - * yet (so it doesn't draw an empty box at startup). + * The sidebar now hosts a single panel — "Did you know?". The previous + * "Files analyzed" panel was hoisted out into a one-line + * {@link FileReadStatus} indicator above the spinner so it can't push + * the tip card off-screen. */ -function FilesAnalyzedPanel({ - filesRead, -}: { - filesRead: FileReadEntry[]; -}): React.ReactNode { - if (filesRead.length === 0) { - return null; - } - const visible = filesRead.slice(-FILES_PANEL_VISIBLE_ROWS); - const overflow = filesRead.length - visible.length; - const analyzed = filesRead.filter( - (entry) => entry.status === "analyzed" - ).length; +function Sidebar({ tipIndex }: { tipIndex: number }): React.ReactNode { return ( - - - {analyzed}/{filesRead.length} read - - - {visible.map((entry) => ( - - ))} - - {overflow > 0 ? ( - - + {overflow} more - - ) : null} - - ); -} - -function FileReadRow({ entry }: { entry: FileReadEntry }): React.ReactNode { - const isAnalyzed = entry.status === "analyzed"; - const glyph = isAnalyzed ? "✔" : "●"; - const color = isAnalyzed ? COLOR_SUCCESS : COLOR_WARN; - // Show the basename for compactness — full paths blow past the - // sidebar's 36-col width regularly. The tooltip-equivalent (full - // path) is unavailable in OpenTUI, so leave the narrow display. - return ( - - - {glyph} - - - {basename(entry.path)} - + + ); } diff --git a/src/lib/init/ui/opentui-store.ts b/src/lib/init/ui/opentui-store.ts index b83d89940..a97d4d109 100644 --- a/src/lib/init/ui/opentui-store.ts +++ b/src/lib/init/ui/opentui-store.ts @@ -35,10 +35,10 @@ export type SpinnerState = { }; /** - * One entry in the persistent "Files analyzed" panel — every file the - * wizard has read from disk during the session. Status transitions - * `reading` → `analyzed` once the tool returns. Renderers paint the - * matching icon. + * 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`). */ export type FileReadEntry = { path: string; @@ -89,10 +89,11 @@ 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. Used by the live "Files analyzed" panel - * 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. + * the workflow progresses. Surfaced by the inline file-read status + * line in `OpenTuiUI` 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. */ filesRead: FileReadEntry[]; }; @@ -195,7 +196,7 @@ export class WizardStore { /** * Record that the wizard is currently reading a batch of files. * Existing entries (read in earlier batches) keep their status so - * the "Files analyzed" panel preserves history; new entries land + * the file-read status line preserves history; new entries land * with status `reading` and flip to `analyzed` via * `markFilesAnalyzed()` when the tool returns. */ diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 3c3fe18f0..2784f3b65 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -174,10 +174,10 @@ export type WizardUI = AsyncDisposable & { /** * Notify the UI that the wizard is reading the listed files from * disk. Optional — implementations that don't track reads (e.g. - * `LoggingUI`) leave this undefined. `OpenTuiUI` uses it to populate - * a persistent "Files analyzed" panel so the user can see what - * context the AI looked at, instead of losing it in a half-second - * spinner flash. + * `LoggingUI`) leave this undefined. `OpenTuiUI` uses it to drive + * a single-line file-read status indicator above the spinner, so + * the user can see what context the AI looked at instead of + * losing it in a half-second spinner flash. */ recordFilesReading?(paths: string[]): void; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 708b50d10..914b090eb 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -194,12 +194,12 @@ async function handleSuspendedStep( : describeTool(payload)); spin.message(renderInlineMarkdown(truncateForTerminal(message))); - // Persistent "Files analyzed" panel (OpenTuiUI only — `LoggingUI` + // Inline file-read status line (OpenTuiUI only — `LoggingUI` // leaves these methods undefined). The previous flow showed a // half-second tree of files in the spinner before the next tool // overwrote it; users couldn't see what context the wizard - // looked at. We feed the read paths into the panel before the - // tool runs, then mark them analyzed afterwards. + // looked at. We feed the read paths into the status indicator + // before the tool runs, then mark them analyzed afterwards. if (payload.operation === "read-files") { ui.recordFilesReading?.(payload.params.paths); } From 52b61fe98342c13ac48503a8c83043f50ef4b8d5 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:42:36 +0000 Subject: [PATCH 16/20] fix(init): embed opentui-app.tsx so binary build doesn't trip Bun bundler bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was failing on `Build Binary (linux-x64)` and `Build Binary (linux-x64-musl)`. Two underlying problems: 1. `@opentui/core` ships Bun-specific `import "..." with { type: "file" }` syntax for tree-sitter assets (*.scm, *.wasm) that esbuild can't parse. 2. Even if we externalize OpenTUI, Bun.compile mangles React's CJS `jsx-runtime` when it's reached through static imports bundled inside `__commonJS` scope — produces malformed output with a TDZ `init_react` symbol that crashes the binary at startup with a SyntaxError. Both issues converge on: don't let the bundlers (esbuild OR Bun.compile) statically resolve React/OpenTUI inside the bundled graph. The fix has three pieces: **** adds a Bun-specific `with { type: "file" }` import for the local React tree: import opentuiAppPath from "./opentui-app.tsx" with { type: "file" }; At compile time Bun copies the .tsx bytes into the binary's virtual filesystem and replaces the import with a runtime path string. The factory then `await import(opentuiAppPath)`s that path — Bun's runtime (not its bundler) resolves React + `@opentui/react` fresh, outside the buggy bundler path. The trade-off is a small first-invocation parse overhead. **** is extended to handle the `file` attribute alongside the existing `text` one. For `type: "file"` the plugin copies the source into the bundle's output directory and marks the import external so esbuild leaves the original `with { type: "file" }` clause intact for Bun.compile to pick up downstream. **** externalizes the entire OpenTUI + React stack from esbuild (`@opentui/core`, `@opentui/core/*`, `@opentui/react`, `@opentui/react/*`, `react`, `react/*`) and adds the sidecar `dist-bin/opentui-app.tsx` to the cleanup step so it doesn't ship as a release artifact. Verified locally: `./dist-bin/sentry-linux-x64 --version` returns correctly, `init --yes` runs through to summary. 6248/6248 unit tests pass; typecheck, ultracite, and `check:deps` all clean. The `@ts-expect-error` on the new import gets auto-removed once `@types/bun` ships a declaration for the `with { type: "file" }` attribute. --- script/build.ts | 37 +++++++++++++++-- script/text-import-plugin.ts | 78 ++++++++++++++++++++++++++++------- src/lib/init/ui/opentui-ui.ts | 34 ++++++++++++++- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/script/build.ts b/script/build.ts index c320d69dc..d1ad7532e 100644 --- a/script/build.ts +++ b/script/build.ts @@ -124,7 +124,34 @@ async function bundleJs(): Promise { platform: "node", target: "esnext", format: "esm", - external: ["bun:*"], + // Externalize the OpenTUI + React stack from the esbuild + // bundling step. Two reasons: + // + // 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. + external: [ + "bun:*", + "@opentui/core", + "@opentui/core/*", + "@opentui/react", + "@opentui/react/*", + "react", + "react/*", + ], sourcemap: "linked", // Minify syntax and whitespace but NOT identifiers. Bun.build minify: true, @@ -480,8 +507,12 @@ async function build(): Promise { // Step 3: Upload the composed sourcemap to Sentry (after compilation) await uploadSourcemapToSentry(); - // Clean up intermediate bundle (only the binaries are artifacts) - await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`; + // Clean up intermediate bundle (only the binaries are artifacts). + // The `opentui-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`; // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/text-import-plugin.ts b/script/text-import-plugin.ts index 9533075dd..8e9627795 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -1,17 +1,35 @@ /** - * esbuild plugin that polyfills Bun's `with { type: "text" }` import - * attribute (esbuild only supports `json`). Intercepts matching - * imports, reads the file, and default-exports its contents as a - * string. Runtime behavior matches Bun's native handling. + * esbuild plugin that polyfills Bun's `with { type: "text" }` and + * `with { type: "file" }` import attributes (esbuild only supports + * `json`). + * + * - `text` — intercepts the import, reads the file, and default- + * exports its contents as a string. Runtime behavior matches Bun's + * native handling. + * - `file` — copies the source file into the esbuild output + * directory, then marks the import external so the original + * `import path from "./foo" with { type: "file" }` clause + * survives in the bundled JS. Bun.compile downstream understands + * the attribute natively, embeds the file as a binary asset, and + * resolves the import to a virtual-filesystem path string at + * runtime. * * Used by `script/build.ts` (single-file executable) and - * `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. + * `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. */ -import { readFileSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; +import { copyFileSync, readFileSync } from "node:fs"; +import { basename, dirname, resolve as resolvePath } from "node:path"; import type { Plugin } from "esbuild"; const TEXT_IMPORT_NS = "text-import"; @@ -21,13 +39,43 @@ export const textImportPlugin: Plugin = { name: "text-import", setup(build) { build.onResolve({ filter: ANY_FILTER }, (args) => { - if (args.with?.type !== "text") { - return null; + if (args.with?.type === "text") { + return { + path: resolvePath(args.resolveDir, args.path), + namespace: TEXT_IMPORT_NS, + }; } - return { - path: resolvePath(args.resolveDir, args.path), - namespace: TEXT_IMPORT_NS, - }; + if (args.with?.type === "file") { + // Copy the source into the bundle's output directory and + // rewrite the import path so it sits next to the bundle. + // esbuild keeps the import external (preserving the + // `with { type: "file" }` clause) so Bun.compile can pick + // it up from the new location. The copy is needed because + // Bun.compile resolves imports relative to the bundle file's + // directory at compile time, not the original source. + const sourcePath = resolvePath(args.resolveDir, args.path); + const outdir = build.initialOptions.outdir + ? resolvePath(build.initialOptions.outdir) + : dirname(resolvePath(build.initialOptions.outfile ?? ".")); + const filename = basename(sourcePath); + const copyPath = resolvePath(outdir, filename); + try { + copyFileSync(sourcePath, copyPath); + } catch (err) { + // Surface the failure so the build fails visibly rather + // than producing a binary that crashes at startup. + throw new Error( + `text-import-plugin: failed to copy ${sourcePath} → ${copyPath}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + return { + path: `./${filename}`, + external: true, + }; + } + return null; }); build.onLoad({ filter: ANY_FILTER, namespace: TEXT_IMPORT_NS }, (args) => { const content = readFileSync(args.path, "utf-8"); diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index e9f75f3e4..616d9b9b4 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -106,6 +106,32 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { return "success"; } +/** + * Embed `opentui-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 + * specifier with the embedded path string at runtime. The + * `text-import-plugin.ts` polyfill in `script/build.ts` mirrors this + * 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. + * + * 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. + */ +// @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" }; + /** * Async factory for `OpenTuiUI`. Imports `@opentui/core`, * `@opentui/react`, `react`, and the local `App` component lazily, @@ -121,7 +147,13 @@ export async function createOpenTuiUI(): Promise { const core = await import("@opentui/core"); const reactBindings = await import("@opentui/react"); const react = await import("react"); - const app = await import("./opentui-app.js"); + // 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. + const app = (await import( + opentuiAppPath + )) as typeof import("./opentui-app.js"); const renderer = await core.createCliRenderer({ exitOnCtrlC: false, From e9b1bf10f2bfe49f4ce569a59f7bba1ca5160f33 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:07:34 +0000 Subject: [PATCH 17/20] fix(build): mkdir output dir before copying with-file sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `text-import-plugin` extension assumed the bundle's `outdir`/`outfile` directory already existed when the `with { type: "file" }` resolution fired. That's true for the binary build (`script/build.ts` mkdirs `dist-bin/` early), but the npm bundle (`script/bundle.ts`) lets esbuild create `dist/` on output write — which happens after the plugin tries to copy. Result: CI's `Build npm Package` jobs failed with text-import-plugin: failed to copy …/src/lib/init/ui/opentui-app.tsx → …/dist/opentui-app.tsx: ENOENT: no such file or directory Fix: `mkdirSync(outdir, { recursive: true })` before `copyFileSync`. Idempotent and cheap. Also tidy the npm bundle cleanup to remove the stray sidecar `dist/opentui-app.tsx` that the plugin produces. The npm distribution gates OpenTuiUI to the Bun binary so the sidecar is never read at runtime, and `package.json#files` already excludes it from the published tarball — but having it sitting in `dist/` locally is just clutter. --- script/bundle.ts | 13 +++++++++++++ script/text-import-plugin.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/script/bundle.ts b/script/bundle.ts index 35c5c8e69..c88eaba69 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -293,6 +293,19 @@ 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 +// 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. +try { + await unlink("./dist/opentui-app.tsx"); +} catch { + // Sidecar may not exist (e.g. plugin path not exercised) — fine. +} + // Calculate bundle size (only the main bundle, not source maps) const bundleOutput = result.metafile?.outputs["dist/index.cjs"]; const bundleSize = bundleOutput?.bytes ?? 0; diff --git a/script/text-import-plugin.ts b/script/text-import-plugin.ts index 8e9627795..ea6c81148 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -28,7 +28,7 @@ * Bun's runtime (not bundler), which doesn't have the bug. */ -import { copyFileSync, readFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; import { basename, dirname, resolve as resolvePath } from "node:path"; import type { Plugin } from "esbuild"; @@ -53,6 +53,15 @@ export const textImportPlugin: Plugin = { // it up from the new location. The copy is needed because // 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. const sourcePath = resolvePath(args.resolveDir, args.path); const outdir = build.initialOptions.outdir ? resolvePath(build.initialOptions.outdir) @@ -60,6 +69,7 @@ export const textImportPlugin: Plugin = { const filename = basename(sourcePath); const copyPath = resolvePath(outdir, filename); try { + mkdirSync(outdir, { recursive: true }); copyFileSync(sourcePath, copyPath); } catch (err) { // Surface the failure so the build fails visibly rather From 80970ce08d3da6582c2e270db9b46f1f903882a2 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:24:38 +0000 Subject: [PATCH 18/20] fix(init): bypass Bun module cache collision in OpenTUI UI The static `with { type: "file" }` import of `opentui-app.tsx` and the dynamic `await import(opentuiAppPath)` in `createOpenTuiUI` resolve to the same absolute path, which Bun's module loader treats as a single cache entry. The first lookup populates the cache with a synthetic `{ __esModule, default: undefined }` shape (the file-resource representation), so the dynamic import returns that shape instead of evaluating the .tsx, leaving `app.App === undefined`. React's reconciler then throws "Element type is invalid". Adding a `?bridge=1` query string to the dynamic import specifier gives Bun a distinct cache key while resolving to the same on-disk file. The .tsx evaluates normally and `App` is exported as expected. --- src/lib/init/ui/opentui-ui.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index 616d9b9b4..bd6563f5f 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -151,8 +151,19 @@ export async function createOpenTuiUI(): Promise { // 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` + // 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 + `${opentuiAppPath}?bridge=1` )) as typeof import("./opentui-app.js"); const renderer = await core.createCliRenderer({ From 59462aea8fd492453814acce3f85fe0ea7fa46c7 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:16:44 +0000 Subject: [PATCH 19/20] feat(init): files-read tree + step progress checklist in sidebar The OpenTUI sidebar previously hosted a single 'Did you know?' tip panel; everything else (file-read activity, workflow progress) was either ephemeral (spinner messages) or absent (no progress indicator). This adds two new sidebar panels stacked below the tip card, giving users a richer at-a-glance view of what the wizard is doing without changing the main column or the imperative `WizardUI` surface in any breaking way. Sidebar layout (top-to-bottom, on terminals \u2265 100 cols): 1. Did you know? \u2014 unchanged, fixed at 12 rows so it can never be squashed off-screen by content below. 2. Progress (n/total) \u2014 static checklist of nine canonical workflow steps. Rows transition pending \u2192 in_progress \u2192 completed (or skipped, or failed) in place. The 'select-target-app', 'resolve-dir', and 'check-existing-sentry' plumbing steps are intentionally excluded from the visible allowlist so the panel stays compact. 3. Files analyzed (n/total) \u2014 scrollable directory tree of every file the wizard has read. Built on OpenTUI's `` with sticky-bottom tracking so newly-read files always come into view, like a tail -f. Active reads show '\u25d0 file.ts' in accent purple; analyzed files dim to muted-green '\u2713 file.ts'. Hidden until at least one file has been recorded \u2014 no empty box during the auth/discover phase. On narrow terminals (< 100 cols) the entire sidebar is hidden as before; the inline 'Reading X, Y \u2026 (n/m analyzed)' line in the main column takes over the file-read indicator role. The Sidebar component owns the breakpoint check via the `showFileReadInline` prop on MainColumn so the responsive switch stays in one place. Implementation details: - `WizardUI` gains an optional `setStep(stepId, status)` method. `LoggingUI` leaves it undefined; the running log already narrates progress for the non-interactive path. `OpenTuiUI` translates each call into a `WizardStore` mutation. - The wizard runner threads step transitions through the suspend/resume loop via a single `activeStepId` cursor. A step is marked `in_progress` on first suspend (idempotent for multi-suspend read-files \u2192 analyze sequences); the previous step flips to `completed` when `stepId` changes; the active step flips to `failed` on the catch path before the wizard tears down. - The store implements implicit skip back-fill: when a step transitions to `in_progress`, any earlier `pending` step (per the new `CANONICAL_STEP_ORDER` constant) is back-filled to `skipped`. The workflow can only move forward, so an earlier pending step that the runner walked past was bypassed by an if-branch \u2014 no need for the runner to announce skips explicitly. - The store's mutators preserve array reference equality on no-op transitions so `useSyncExternalStore` doesn't trigger spurious React re-renders. A unit test verifies idempotency of `setStepStatus` for the multi-suspend case. - The shared `buildReadTree` helper in `file-tree.ts` mirrors `buildFileTree` (changed-files) but tags leaves with read status instead of change action and preserves insertion order (no sort) so sticky-bottom scrollbox tracking works as expected. `FileTreeRow` gains an optional `status` field alongside `action`. - Fragment-shortened sidebar labels live in `STEP_LABELS_SHORT` next to the existing full `STEP_LABELS`, picked via `shortStepLabel(id)`. The full labels stay the source of truth for the spinner message in the main column. Tests: - New `test/lib/init/ui/file-tree.test.ts` covers the `buildReadTree` builder: empty input, nesting, status propagation, insertion-order preservation, no collisions with the sorted changed-files builder, and dedup of intermediate directories. - New `test/lib/init/ui/opentui-store.test.ts` covers the step state machine: pre-population, idempotent re-entry, back-fill behaviour, allowlist filtering, completed/failed precedence, skip clobber-protection, and subscriber notification semantics. - `MockUI` records `recordFilesReading`, `markFilesAnalyzed`, and `setStep` calls so wizard-runner tests can assert on them without coupling to a concrete UI. Verification: - bun run typecheck (clean) - bun x biome check src/ test/ (1 pre-existing warning, no new ones) - bun test test/lib/init/ (208 pass, was 192 \u2014 16 new tests) - SENTRY_CLIENT_ID=test bun run build (binary 118.24 MB, +0.01 MB) - SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, +1.7 KB) - ./dist-bin/sentry-linux-x64 init --help (renders cleanly) - Smoke test creating an OpenTuiUI and exercising recordFilesReading, markFilesAnalyzed, setStep, spinner, summary, and dispose paths produced no React reconciler errors. --- src/lib/init/clack-utils.ts | 76 ++++++++ src/lib/init/ui/file-tree.ts | 77 +++++++- src/lib/init/ui/opentui-app.tsx | 246 ++++++++++++++++++++++++- src/lib/init/ui/opentui-store.ts | 153 +++++++++++++++ src/lib/init/ui/opentui-ui.ts | 7 + src/lib/init/ui/types.ts | 22 +++ src/lib/init/wizard-runner.ts | 37 ++++ test/lib/init/ui/file-tree.test.ts | 103 +++++++++++ test/lib/init/ui/mock-ui.ts | 15 +- test/lib/init/ui/opentui-store.test.ts | 150 +++++++++++++++ 10 files changed, 873 insertions(+), 13 deletions(-) create mode 100644 test/lib/init/ui/file-tree.test.ts create mode 100644 test/lib/init/ui/opentui-store.test.ts diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index f94157f42..c16297295 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -123,3 +123,79 @@ export const STEP_LABELS: Record = { "verify-changes": "Verifying changes", "open-sentry-ui": "Finishing up", }; + +/** + * Canonical execution order of the wizard's workflow steps. + * + * Used by the OpenTUI 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 + * can only move forward, so a later transition implies any earlier + * pending step was bypassed by an `if`-branch in the workflow). + * + * Order must match the actual Mastra workflow order or the back-fill + * logic will mis-mark steps as skipped. + */ +export const CANONICAL_STEP_ORDER: readonly string[] = [ + "discover-context", + "select-target-app", + "resolve-dir", + "check-existing-sentry", + "detect-platform", + "ensure-sentry-project", + "select-features", + "install-deps", + "plan-codemods", + "apply-codemods", + "verify-changes", + "open-sentry-ui", +]; + +/** + * Subset of {@link CANONICAL_STEP_ORDER} surfaced in the progress + * checklist. The OpenTUI 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. + * + * The hidden steps (`select-target-app`, `resolve-dir`, + * `check-existing-sentry`) are plumbing — users care that "Setting up + * Sentry project" happened, not that we resolved their working + * directory along the way. + */ +export const CHECKLIST_VISIBLE_STEPS: readonly string[] = [ + "discover-context", + "detect-platform", + "ensure-sentry-project", + "select-features", + "install-deps", + "plan-codemods", + "apply-codemods", + "verify-changes", + "open-sentry-ui", +]; + +/** + * Sidebar-friendly abbreviations of {@link STEP_LABELS}. The full + * labels stay the source-of-truth for the spinner message in the main + * column; only the 36-col sidebar checklist uses these. + * + * Falls back to the full label if a step isn't listed here. + */ +export const STEP_LABELS_SHORT: Record = { + "discover-context": "Analyzing project", + "detect-platform": "Detecting platform", + "ensure-sentry-project": "Setting up project", + "select-features": "Selecting features", + "install-deps": "Installing deps", + "plan-codemods": "Planning changes", + "apply-codemods": "Applying changes", + "verify-changes": "Verifying changes", + "open-sentry-ui": "Finishing up", +}; + +/** Resolve a step id to its sidebar checklist label. */ +export function shortStepLabel(stepId: string): string { + return STEP_LABELS_SHORT[stepId] ?? STEP_LABELS[stepId] ?? stepId; +} diff --git a/src/lib/init/ui/file-tree.ts b/src/lib/init/ui/file-tree.ts index 9904523b6..e1720e967 100644 --- a/src/lib/init/ui/file-tree.ts +++ b/src/lib/init/ui/file-tree.ts @@ -18,6 +18,16 @@ export type ChangedFile = { path: string; }; +/** + * One entry in the read-files tree. `status` mirrors the + * `FileReadEntry.status` shape from the wizard store so the OpenTUI + * `FilesPanel` can render an at-a-glance icon per row. + */ +export type ReadFile = { + path: string; + status: "reading" | "analyzed"; +}; + export type FileTreeNode = { /** Path segment for this node (e.g. "src", "router.tsx"). */ name: string; @@ -28,6 +38,13 @@ export type FileTreeNode = { path?: string; /** Action recorded by the workflow — only on leaf nodes. */ action?: string; + /** + * Read-progress status for the leaf — only set when the tree is + * built from read entries (vs. changed files, which carry `action` + * instead). Mutually exclusive with {@link FileTreeNode.action} in + * practice; consumers branch on whichever is populated. + */ + status?: "reading" | "analyzed"; children: FileTreeNode[]; }; @@ -51,8 +68,13 @@ export type FileTreeRow = { label: string; /** Full path — only set on `file` rows. */ path?: string; - /** Action — only set on `file` rows. */ + /** Action — only set on `file` rows from a changed-files tree. */ action?: string; + /** + * Read-progress status — only set on `file` rows from a read-files + * tree. Mutually exclusive with `action` in practice. + */ + status?: "reading" | "analyzed"; }; function splitPath(filePath: string): string[] { @@ -153,7 +175,14 @@ function rowFor( prefix: string, isLast: boolean ): FileTreeRow { - const isFile = Boolean(node.action); + // Files are leaves that carry either a change `action` (from + // `buildFileTree`) or a read `status` (from `buildReadTree`). A + // node with neither but a `path` set is also a file — covers + // future tree builders that don't tag leaves. + const isFile = + Boolean(node.action) || + Boolean(node.status) || + (node.path !== undefined && node.children.length === 0); return { prefix, branch: isLast ? "└─" : "├─", @@ -161,5 +190,49 @@ function rowFor( label: isFile ? node.name : `${node.name}/`, ...(node.path !== undefined ? { path: node.path } : {}), ...(node.action !== undefined ? { action: node.action } : {}), + ...(node.status !== undefined ? { status: node.status } : {}), }; } + +/** + * Build a directory tree from the wizard's read-files list. Mirrors + * {@link buildFileTree} but tags leaves with `status` instead of + * `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. + */ +export function buildReadTree(files: ReadFile[]): FileTreeNode { + const root: FileTreeNode = { name: "", children: [] }; + const childIndex = new WeakMap>(); + childIndex.set(root, new Map()); + + for (const file of files) { + const parts = splitPath(file.path); + let current = root; + + for (const [index, part] of parts.entries()) { + const map = childIndex.get(current) ?? new Map(); + let child = map.get(part); + if (!child) { + child = { name: part, children: [] }; + map.set(part, child); + childIndex.set(current, map); + childIndex.set(child, new Map()); + current.children.push(child); + } + + if (index === parts.length - 1) { + child.path = file.path; + child.status = file.status; + } + + current = child; + } + } + + // Deliberately no `sortRecursive(root)` — keep insertion order so + // sticky-bottom scrollbox tracking feels right. + return root; +} diff --git a/src/lib/init/ui/opentui-app.tsx b/src/lib/init/ui/opentui-app.tsx index dc35fb75e..8cddb52cf 100644 --- a/src/lib/init/ui/opentui-app.tsx +++ b/src/lib/init/ui/opentui-app.tsx @@ -37,13 +37,19 @@ import { basename } from "node:path"; import { useKeyboard, useTerminalDimensions } from "@opentui/react"; import { useState, useSyncExternalStore } from "react"; -import { buildFileTree, type FileTreeRow, flattenTree } from "./file-tree.js"; +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"; @@ -99,6 +105,25 @@ const SIDEBAR_WIDTH = 36; */ 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. @@ -132,10 +157,17 @@ export function App({ store }: AppProps): React.ReactNode { filesRead={snapshot.filesRead} logs={snapshot.logs} prompt={snapshot.prompt} + showFileReadInline={!showSidebar} spinner={snapshot.spinner} summary={snapshot.summary} /> - {showSidebar ? : null} + {showSidebar ? ( + + ) : null} ); @@ -150,6 +182,13 @@ type MainColumnProps = { 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({ @@ -159,11 +198,12 @@ function MainColumn({ 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 = !summary && filesRead.length > 0; + const showFileStatus = showFileReadInline && !summary && filesRead.length > 0; return (
@@ -577,15 +617,33 @@ function MultiSelectPrompt({ // ────────────────────────────── Sidebar ─────────────────────────────── /** - * The sidebar now hosts a single panel — "Did you know?". The previous - * "Files analyzed" panel was hoisted out into a one-line - * {@link FileReadStatus} indicator above the spinner so it can't push - * the tip card off-screen. + * 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 }: { tipIndex: number }): React.ReactNode { +function Sidebar({ + tipIndex, + steps, + filesRead, +}: { + tipIndex: number; + steps: StepEntry[]; + filesRead: FileReadEntry[]; +}): React.ReactNode { return ( - + + + ); } @@ -599,9 +657,9 @@ function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { borderColor={MUTED} borderStyle="rounded" flexDirection="column" - flexGrow={1} flexShrink={0} gap={1} + height={TIP_PANEL_HEIGHT} padding={1} title=" Did you know? " titleAlignment="left" @@ -615,3 +673,171 @@ function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { ); } + +/** + * 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/opentui-store.ts b/src/lib/init/ui/opentui-store.ts index a97d4d109..aa36542fa 100644 --- a/src/lib/init/ui/opentui-store.ts +++ b/src/lib/init/ui/opentui-store.ts @@ -16,6 +16,11 @@ * to detect changes. */ +import { + CANONICAL_STEP_ORDER, + CHECKLIST_VISIBLE_STEPS, + shortStepLabel, +} from "../clack-utils.js"; import type { SpinnerExitCode, WizardSummary } from "./types.js"; export type LogSeverity = "info" | "warn" | "error" | "success" | "message"; @@ -45,6 +50,32 @@ export type FileReadEntry = { status: "reading" | "analyzed"; }; +/** + * Status of a single workflow step in the sidebar progress checklist. + * + * - `pending` — runner hasn't reached this step yet. + * - `in_progress` — runner is suspended on this step. + * - `completed` — runner has resumed past this step. + * - `skipped` — workflow's branching bypassed this step + * (back-filled implicitly when a later step starts). + * - `failed` — runner aborted while this step was active. + */ +export type StepStatus = + | "pending" + | "in_progress" + | "completed" + | "skipped" + | "failed"; + +/** One row in the sidebar progress checklist. */ +export type StepEntry = { + /** Mastra step id (e.g. `"discover-context"`). */ + id: string; + /** Sidebar-friendly short label (already abbreviated). */ + label: string; + status: StepStatus; +}; + /** Generic option shape passed to mounted prompts. */ export type PromptOption = { value: string; @@ -96,6 +127,15 @@ export type WizardSnapshot = { * overwrote it. */ filesRead: FileReadEntry[]; + /** + * Workflow step progress checklist. Pre-populated from + * `CHECKLIST_VISIBLE_STEPS` with every entry as `pending`; the + * runner advertises status changes via `WizardUI.setStep()` and + * the store updates the matching entry in place. Steps not present + * in the visible-step allowlist (e.g. `select-target-app`, + * `resolve-dir`) are silently ignored so the sidebar stays compact. + */ + steps: StepEntry[]; }; export type Listener = () => void; @@ -118,6 +158,13 @@ export class WizardStore { tipIndex: initial.tipIndex ?? 0, summary: initial.summary ?? null, filesRead: initial.filesRead ?? [], + steps: + initial.steps ?? + CHECKLIST_VISIBLE_STEPS.map((id) => ({ + id, + label: shortStepLabel(id), + status: "pending" as StepStatus, + })), }; } @@ -218,6 +265,45 @@ export class WizardStore { this.update({ filesRead: [...byPath.values()] }); } + /** + * Update the status of a workflow step in the sidebar progress + * checklist. + * + * Behavior: + * + * - If `id` is not in {@link CHECKLIST_VISIBLE_STEPS}, the call is + * a no-op — keeps the sidebar compact for plumbing-only steps. + * + * - When transitioning a step to `in_progress`, any earlier + * `pending` step (per {@link CANONICAL_STEP_ORDER}) is + * back-filled to `skipped`. The workflow can only move forward, + * so an earlier pending step that the runner walked past was + * bypassed by an `if`-branch. + * + * - Re-entering an already-`in_progress` step is a no-op (a step + * can suspend multiple times — read-files, analyze, etc. — and + * the checklist should only flip on the first entry). + * + * - `completed` / `failed` always overwrite. `skipped` only + * applies if the step is currently `pending` (avoid clobbering + * a completed step). + */ + setStepStatus(id: string, status: StepStatus): void { + const canonicalIndex = CANONICAL_STEP_ORDER.indexOf(id); + + let nextSteps = this.snapshot.steps; + if (status === "in_progress" && canonicalIndex >= 0) { + nextSteps = backfillSkippedSteps(nextSteps, canonicalIndex); + } + if (CHECKLIST_VISIBLE_STEPS.includes(id)) { + nextSteps = applyStepStatus(nextSteps, id, status); + } + + if (nextSteps !== this.snapshot.steps) { + this.update({ steps: nextSteps }); + } + } + /** * Flip the matching entries in `filesRead` from `reading` to * `analyzed`. Paths not present in the store are added as @@ -281,3 +367,70 @@ export class WizardStore { return "✔"; } } + +/** + * Back-fill any `pending` step whose canonical position is earlier + * than `startedIndex` to `skipped`. The workflow can only move + * forward, so a still-pending earlier step that the runner walked + * past was bypassed by an `if`-branch. + * + * Returns the original array reference if nothing changed — the + * store relies on this to skip subscriber notifications for no-op + * mutations. + */ +function backfillSkippedSteps( + steps: StepEntry[], + startedIndex: number +): StepEntry[] { + let changed = false; + const candidate = steps.map((entry) => { + if (entry.status !== "pending") { + return entry; + } + const entryIndex = CANONICAL_STEP_ORDER.indexOf(entry.id); + if (entryIndex >= 0 && entryIndex < startedIndex) { + changed = true; + return { ...entry, status: "skipped" as StepStatus }; + } + return entry; + }); + return changed ? candidate : steps; +} + +/** + * Apply a status update to the matching step entry, with idempotency + * and clobber-protection rules: + * + * - Re-entering an already-`in_progress` step is a no-op (the same + * step can suspend multiple times). + * - Explicit `skipped` only wins when the row is currently + * `pending` — protects against accidentally clobbering a + * completed step. + * - `completed` / `failed` always overwrite. + * + * Returns the original array reference when the update is a no-op + * so subscribers aren't notified. + */ +function applyStepStatus( + steps: StepEntry[], + id: string, + status: StepStatus +): StepEntry[] { + const targetIndex = steps.findIndex((entry) => entry.id === id); + if (targetIndex === -1) { + return steps; + } + const current = steps[targetIndex]; + if (!current) { + return steps; + } + if (status === current.status) { + return steps; + } + if (status === "skipped" && current.status !== "pending") { + return steps; + } + const updated = [...steps]; + updated[targetIndex] = { ...current, status }; + return updated; +} diff --git a/src/lib/init/ui/opentui-ui.ts b/src/lib/init/ui/opentui-ui.ts index bd6563f5f..7e22cb082 100644 --- a/src/lib/init/ui/opentui-ui.ts +++ b/src/lib/init/ui/opentui-ui.ts @@ -294,6 +294,13 @@ export class OpenTuiUI implements WizardUI { this.store.markFilesAnalyzed(paths); } + setStep( + stepId: string, + status: "in_progress" | "completed" | "failed" | "skipped" + ): void { + this.store.setStepStatus(stepId, status); + } + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog = { diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 2784f3b65..48554469f 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -187,6 +187,28 @@ export type WizardUI = AsyncDisposable & { */ markFilesAnalyzed?(paths: string[]): void; + /** + * Notify the UI that a workflow step has changed status. Optional — + * `LoggingUI` leaves this undefined since the running log already + * narrates progress. `OpenTuiUI` uses it to drive the static + * progress checklist in the sidebar. + * + * Status semantics: + * - `"in_progress"` — the runner just suspended on this step. + * Idempotent: a step that suspends multiple times (read-files + * followed by analyze, etc.) only flips to in_progress once. + * - `"completed"` — the runner has resumed past this step. + * - `"failed"` — the runner aborted while this step was + * active. + * - `"skipped"` — the workflow's branching skipped this step + * entirely. In practice the store back-fills this implicitly + * when a later step starts, so callers rarely need to pass it. + */ + setStep?( + stepId: string, + status: "in_progress" | "completed" | "failed" | "skipped" + ): void; + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 914b090eb..4b68dba2c 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -572,6 +572,14 @@ export async function runWizard(initialOptions: WizardOptions): Promise { const stepPhases = new Map(); const stepHistory = new Map[]>(); + // Track which step the runner is currently suspended on so the + // OpenTUI sidebar checklist can flip rows as the workflow advances. + // A single step can suspend multiple times (read-files → analyze → + // done); `setStep("...", "in_progress")` is idempotent in the + // store, and we only fire the `completed` transition when the + // active step changes. + let activeStepId: string | undefined; + try { while (result.status === "suspended") { const stepPath = result.suspended?.at(0) ?? []; @@ -581,11 +589,24 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (!extracted) { spin.stop("Error", 1); spinState.running = false; + if (activeStepId) { + ui.setStep?.(activeStepId, "failed"); + } ui.log.error(`No suspend payload found for step "${stepId}"`); ui.cancel("Setup failed"); throw new WizardError(`No suspend payload found for step "${stepId}"`); } + // Step transition: if the active step just changed, mark the + // previous one completed before flipping this one to + // in_progress. The store back-fills any earlier `pending` + // entries as `skipped` on the in_progress transition. + if (activeStepId && activeStepId !== extracted.stepId) { + ui.setStep?.(activeStepId, "completed"); + } + activeStepId = extracted.stepId; + ui.setStep?.(extracted.stepId, "in_progress"); + const resumeData = await handleSuspendedStep( { payload: extracted.payload, @@ -623,10 +644,17 @@ export async function runWizard(initialOptions: WizardOptions): Promise { spinState.running = false; } if (err instanceof WizardCancelledError) { + // Cancellation is a clean exit, not a failure — leave the + // active step as `in_progress` rather than flipping it to + // failed; the post-dispose report shows the cancel message + // instead. captureException(err); process.exitCode = 0; return; } + if (activeStepId) { + ui.setStep?.(activeStepId, "failed"); + } if (err instanceof WizardError) { throw err; } @@ -635,6 +663,15 @@ export async function runWizard(initialOptions: WizardOptions): Promise { throw new WizardError(errorMessage(err)); } + // Workflow exited the suspend loop successfully — mark the last + // active step (if any) as completed before the final-result handler + // emits its outcome line. Status === "success" implies the final + // step finished; failure paths run through the catch above and + // already marked the step `failed`. + if (activeStepId && result.status === "success") { + ui.setStep?.(activeStepId, "completed"); + } + handleFinalResult(result, spin, spinState, ui); } diff --git a/test/lib/init/ui/file-tree.test.ts b/test/lib/init/ui/file-tree.test.ts new file mode 100644 index 000000000..b329e32a5 --- /dev/null +++ b/test/lib/init/ui/file-tree.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for the shared file-tree builder used by both the OpenTUI + * sidebar (read-files panel + changed-files summary) and the + * post-dispose stderr report. + * + * Two builders share `flattenTree`: + * - `buildFileTree(changed)` — sorts directories first, then alpha + * - `buildReadTree(reads)` — preserves insertion order so the + * OpenTUI scrollbox's sticky-bottom tracking feels right + * + * The tests below exercise the second builder explicitly since it's + * new in this PR; the changed-files builder already has implicit + * coverage via the existing `formatters.test.ts` snapshot tests. + */ + +import { describe, expect, test } from "bun:test"; +import { + buildFileTree, + buildReadTree, + flattenTree, +} from "../../../../src/lib/init/ui/file-tree.js"; + +describe("buildReadTree", () => { + test("returns empty tree for empty input", () => { + const tree = buildReadTree([]); + expect(tree.children).toHaveLength(0); + }); + + test("nests files under their parent directories", () => { + const tree = buildReadTree([ + { path: "src/index.ts", status: "analyzed" }, + { path: "src/lib/foo.ts", status: "reading" }, + { path: "package.json", status: "analyzed" }, + ]); + + const rows = flattenTree(tree); + const labels = rows.map((row) => `${row.kind}:${row.label}`); + // Directory rows have trailing slash, files don't. + expect(labels).toContain("directory:src/"); + expect(labels).toContain("directory:lib/"); + expect(labels).toContain("file:index.ts"); + expect(labels).toContain("file:foo.ts"); + expect(labels).toContain("file:package.json"); + }); + + test("propagates status onto leaf rows", () => { + const tree = buildReadTree([ + { path: "a.ts", status: "reading" }, + { path: "b.ts", status: "analyzed" }, + ]); + const fileRows = flattenTree(tree).filter((row) => row.kind === "file"); + expect(fileRows.find((row) => row.label === "a.ts")?.status).toBe( + "reading" + ); + expect(fileRows.find((row) => row.label === "b.ts")?.status).toBe( + "analyzed" + ); + }); + + test("preserves insertion order (no sort)", () => { + // Sorting would put `aa.ts` before `bb.ts`. We deliberately + // insert in reverse-alphabetical order to verify that the + // builder doesn't reorder — sticky-bottom scrollbox tracking + // depends on newly-added files always landing at the end. + const tree = buildReadTree([ + { path: "src/zz.ts", status: "analyzed" }, + { path: "src/aa.ts", status: "analyzed" }, + { path: "src/mm.ts", status: "analyzed" }, + ]); + const fileLabels = flattenTree(tree) + .filter((row) => row.kind === "file") + .map((row) => row.label); + expect(fileLabels).toEqual(["zz.ts", "aa.ts", "mm.ts"]); + }); + + test("does not collide with the sorted changed-files tree", () => { + // Sanity-check: feeding the same paths through `buildFileTree` + // sorts alphabetically. The two builders must stay independent. + const sorted = buildFileTree([ + { action: "modify", path: "src/zz.ts" }, + { action: "modify", path: "src/aa.ts" }, + ]); + const sortedLabels = flattenTree(sorted) + .filter((row) => row.kind === "file") + .map((row) => row.label); + expect(sortedLabels).toEqual(["aa.ts", "zz.ts"]); + }); + + test("does not duplicate intermediate directories", () => { + const tree = buildReadTree([ + { path: "src/a/foo.ts", status: "analyzed" }, + { path: "src/a/bar.ts", status: "analyzed" }, + { path: "src/b/baz.ts", status: "analyzed" }, + ]); + const dirLabels = flattenTree(tree) + .filter((row) => row.kind === "directory") + .map((row) => row.label); + // `src/` should appear once, not three times. + expect(dirLabels.filter((label) => label === "src/")).toHaveLength(1); + expect(dirLabels.filter((label) => label === "a/")).toHaveLength(1); + expect(dirLabels.filter((label) => label === "b/")).toHaveLength(1); + }); +}); diff --git a/test/lib/init/ui/mock-ui.ts b/test/lib/init/ui/mock-ui.ts index 9d24ea0a2..180930f4a 100644 --- a/test/lib/init/ui/mock-ui.ts +++ b/test/lib/init/ui/mock-ui.ts @@ -45,7 +45,14 @@ export type MockCall = options: string[]; initialValues?: string[]; } - | { kind: "confirm"; message: string; initialValue?: boolean }; + | { kind: "confirm"; message: string; initialValue?: boolean } + | { kind: "recordFilesReading"; paths: string[] } + | { kind: "markFilesAnalyzed"; paths: string[] } + | { + kind: "setStep"; + stepId: string; + status: "in_progress" | "completed" | "failed" | "skipped"; + }; /** * Programmable prompt response. `value` is what the impl returns when @@ -113,6 +120,12 @@ export function createMockUI(): { summary: (summary) => calls.push({ kind: "summary", summary }), outro: (message) => calls.push({ kind: "outro", message }), cancel: (message) => calls.push({ kind: "cancel", message }), + recordFilesReading: (paths) => + calls.push({ kind: "recordFilesReading", paths }), + markFilesAnalyzed: (paths) => + calls.push({ kind: "markFilesAnalyzed", paths }), + setStep: (stepId, status) => + calls.push({ kind: "setStep", stepId, status }), log, spinner, select: (opts: SelectOptions) => { diff --git a/test/lib/init/ui/opentui-store.test.ts b/test/lib/init/ui/opentui-store.test.ts new file mode 100644 index 000000000..d59af5c47 --- /dev/null +++ b/test/lib/init/ui/opentui-store.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for the OpenTUI wizard store's step-progress state. + * + * Covers: + * - canonical pre-population from CHECKLIST_VISIBLE_STEPS + * - in_progress / completed transitions + * - implicit skip back-fill when a later step starts + * - 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 + * dev/binary builds. This test file focuses on the pure data layer. + */ + +import { describe, expect, test } from "bun:test"; +import { + CANONICAL_STEP_ORDER, + CHECKLIST_VISIBLE_STEPS, +} from "../../../../src/lib/init/clack-utils.js"; +import { WizardStore } from "../../../../src/lib/init/ui/opentui-store.js"; + +describe("WizardStore step progress", () => { + test("pre-populates the checklist from CHECKLIST_VISIBLE_STEPS", () => { + const store = new WizardStore(); + const snapshot = store.getSnapshot(); + expect(snapshot.steps.map((entry) => entry.id)).toEqual( + CHECKLIST_VISIBLE_STEPS.slice() + ); + expect(snapshot.steps.every((entry) => entry.status === "pending")).toBe( + true + ); + }); + + test("flips a step to in_progress on first call", () => { + const store = new WizardStore(); + store.setStepStatus("ensure-sentry-project", "in_progress"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "ensure-sentry-project"); + expect(entry?.status).toBe("in_progress"); + }); + + test("re-entering an in_progress step is idempotent (no flicker)", () => { + const store = new WizardStore(); + store.setStepStatus("install-deps", "in_progress"); + const before = store.getSnapshot().steps; + store.setStepStatus("install-deps", "in_progress"); + const after = store.getSnapshot().steps; + // Reference equality: no update emitted, so the array is the + // same instance. This is what the store guarantees for no-op + // mutations and what `useSyncExternalStore` relies on. + expect(after).toBe(before); + }); + + test("back-fills earlier pending steps as skipped when a later step starts", () => { + const store = new WizardStore(); + // Jump straight to a later step — simulates the workflow + // taking an `if`-branch that bypassed the earlier ones. + store.setStepStatus("install-deps", "in_progress"); + const steps = store.getSnapshot().steps; + const installIdx = CANONICAL_STEP_ORDER.indexOf("install-deps"); + for (const entry of steps) { + const idx = CANONICAL_STEP_ORDER.indexOf(entry.id); + if (idx >= 0 && idx < installIdx) { + expect(entry.status).toBe("skipped"); + } + } + expect(steps.find((entry) => entry.id === "install-deps")?.status).toBe( + "in_progress" + ); + }); + + test("does not back-fill steps that have already completed", () => { + const store = new WizardStore(); + store.setStepStatus("discover-context", "in_progress"); + store.setStepStatus("discover-context", "completed"); + store.setStepStatus("install-deps", "in_progress"); + const discover = store + .getSnapshot() + .steps.find((row) => row.id === "discover-context"); + expect(discover?.status).toBe("completed"); + }); + + test("ignores stepIds outside the visible allowlist", () => { + const store = new WizardStore(); + // `select-target-app` is in CANONICAL_STEP_ORDER but not in + // CHECKLIST_VISIBLE_STEPS — the call should still drive the + // back-fill on visible earlier rows but not add a new row. + const initialLength = store.getSnapshot().steps.length; + store.setStepStatus("select-target-app", "in_progress"); + // Note: variable is deliberately not named `after` because + // Biome's `noDoneCallback` rule pattern-matches Mocha hooks + // (`after`, `before`, …) by identifier and would flag the + // arrow-function callback inside `.find()` below. + const updated = store.getSnapshot().steps; + expect(updated.length).toBe(initialLength); + // Visible rows earlier than `select-target-app` (i.e. + // `discover-context`) should be back-filled to skipped. + const discover = updated.find((entry) => entry.id === "discover-context"); + expect(discover?.status).toBe("skipped"); + }); + + test("completed transition wins over the existing status", () => { + const store = new WizardStore(); + store.setStepStatus("apply-codemods", "in_progress"); + store.setStepStatus("apply-codemods", "completed"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "apply-codemods"); + expect(entry?.status).toBe("completed"); + }); + + test("failed transition wins over the existing status", () => { + const store = new WizardStore(); + store.setStepStatus("install-deps", "in_progress"); + store.setStepStatus("install-deps", "failed"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "install-deps"); + expect(entry?.status).toBe("failed"); + }); + + test("explicit skipped does not overwrite a completed entry", () => { + const store = new WizardStore(); + store.setStepStatus("discover-context", "in_progress"); + store.setStepStatus("discover-context", "completed"); + // A bogus (and impossible) explicit skip call should be a no-op. + store.setStepStatus("discover-context", "skipped"); + const entry = store + .getSnapshot() + .steps.find((row) => row.id === "discover-context"); + expect(entry?.status).toBe("completed"); + }); + + test("notifies subscribers on step transitions", () => { + const store = new WizardStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + store.setStepStatus("install-deps", "in_progress"); + store.setStepStatus("install-deps", "in_progress"); // idempotent + store.setStepStatus("install-deps", "completed"); + unsubscribe(); + // Two real transitions = two notifications. The middle no-op + // does not fire a listener — saves a render in React. + expect(notifications).toBe(2); + }); +}); From 40741b76f3ff3112b98a53ddef1dc5b552adbc11 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:28:15 +0000 Subject: [PATCH 20/20] chore(init): drop dead clack-plain imports Surfaced by Biome's noUnusedImports rule after rebasing onto latest main. Three init files still imported helpers from clack-plain.js that haven't been used since the migration to WizardUI; the rebase auto-merge left them in place because they didn't conflict literally even though the call sites were rewritten. --- src/lib/init/interactive.ts | 1 - src/lib/init/preflight.ts | 1 - src/lib/init/wizard-runner.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 99617c07a..29ae0ebe6 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -11,7 +11,6 @@ */ import chalk from "chalk"; -import { confirm, log, multiselect, select } from "./clack-plain.js"; import { abortIfCancelled, featureHint, diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index df6e2109b..261bcd8db 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -4,7 +4,6 @@ import { getAuthToken } from "../db/auth.js"; import { WizardError } from "../errors.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { slugify } from "../utils.js"; -import { cancel, isCancel, log, select } from "./clack-plain.js"; import { WizardCancelledError } from "./clack-utils.js"; import { tryGetExistingProjectData } from "./existing-project.js"; import { resolveOrgPrefetched } from "./org-prefetch.js"; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 4b68dba2c..d86d1df1a 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -25,7 +25,6 @@ import { safeCodeSpan, stripColorTags, } from "../formatters/markdown.js"; -import { cancel, confirm, intro, log } from "./clack-plain.js"; import { abortIfCancelled, STEP_LABELS,