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