Skip to content

feat(init): scaffold WizardUI abstraction layer for OpenTUI migration#862

Draft
MathurAditya724 wants to merge 20 commits intomainfrom
feat/init-wizard-ui-abstraction
Draft

feat(init): scaffold WizardUI abstraction layer for OpenTUI migration#862
MathurAditya724 wants to merge 20 commits intomainfrom
feat/init-wizard-ui-abstraction

Conversation

@MathurAditya724
Copy link
Copy Markdown
Member

Summary

PR 1 of a staged migration that replaces @clack/prompts in sentry init
with OpenTUI, a full-screen TUI
library. This PR introduces the abstraction layer only — no observable
behavior changes
.

Inspired by PostHog wizard's
WizardUI pattern (visual look-and-feel only; we skip the screen router,
nanostore, health-check overlays, and todo-sync features).

What's in this PR

A new src/lib/init/ui/ module with a thin I/O abstraction:

File Purpose
types.ts WizardUI interface, SpinnerHandle, shared CANCELLED sentinel + isCancelled() guard
logging-ui.ts Non-interactive impl — plain stdout/stderr, no-op spinner, prompts throw LoggingUIPromptError
clack-ui.ts Interactive impl — thin wrapper over current @clack/prompts calls
factory.ts getUI() selects impl based on SENTRY_INIT_TUI env var, --yes, and stdin/stdout TTY state

Adds @opentui/core@^0.2.0 to devDependencies (dev-only; bundled into
the compiled binary in PR 3). bun run check:deps continues to pass.

What's NOT in this PR

  • No call-site changes. wizard-runner.ts, interactive.ts,
    preflight.ts, formatters.ts, and git.ts still import from
    @clack/prompts directly. PR 2 migrates them to call ui.*.
  • No OpenTUI implementation. OpenTuiUI lands in PR 3 behind a --tui
    flag. PR 4 flips the default and removes clack.

Roadmap

PR Scope
#this Scaffold abstraction + LoggingUI + ClackUI + factory + tests
PR 2 Migrate clack call sites in init/* to use WizardUI (still defaults to ClackUI)
PR 3 Build OpenTuiUI (Bun-binary only); gate behind --tui flag
PR 4 Flip default to OpenTuiUI; eventually remove @clack/prompts and ClackUI

Selection rules (factory.ts)

1. SENTRY_INIT_TUI=0           → LoggingUI (debug escape hatch)
2. --yes flag                  → LoggingUI
3. stdin/stdout not a TTY      → LoggingUI (CI / piped input)
4. Otherwise                   → ClackUI (today)
                                 → OpenTuiUI (after PR 4, on Bun binary)

The Node distribution will keep using a non-OpenTUI implementation
because OpenTUI is Bun-only; PR 3 adds the runtime-detection branch.

Testing

  • 34 new unit tests in `test/lib/init/ui/` covering:
    • `CANCELLED` sentinel + `isCancelled()` type guard
    • `LoggingUI` stream routing (info → stdout, warn/error → stderr),
      spinner lifecycle, prompt rejection with descriptive errors,
      `await using` dispose behavior
    • Factory selection rules across all combinations of TTY state, `--yes`,
      `SENTRY_INIT_TUI`, and `forceLegacy`
  • All 191 existing init tests continue to pass
  • `bun x tsc --noEmit` clean
  • `bun x ultracite check` clean
  • `bun run check:deps` passes (no runtime dependencies)

Why staged?

Big-bang clack→OpenTUI swap would be hard to bisect if regressions appear
on macOS/Linux/WSL or in non-TTY contexts. This sequence keeps each PR
small and reviewable, with `ClackUI` as a safety net until the OpenTUI
implementation has had time to bake.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-862/

Built to branch gh-pages at 2026-04-29 04:54 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Codecov Results 📊

6341 passed | Total: 6341 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests 📈 +52
Passed Tests 📈 +52
Failed Tests
Skipped Tests

All tests are passing successfully.

❌ Patch coverage is 68.45%. Project has 13292 uncovered lines.
❌ Project coverage is 75.83%. Comparing base (base) to head (head).

Files with missing lines (9)
File Patch % Lines
src/lib/init/ui/wizard-store.ts 51.98% ⚠️ 85 Missing
src/lib/init/wizard-runner.ts 43.18% ⚠️ 75 Missing
src/lib/init/ui/logging-ui.ts 60.42% ⚠️ 57 Missing
src/lib/init/ui/file-tree.ts 82.93% ⚠️ 21 Missing
src/lib/init/ui/factory.ts 52.63% ⚠️ 18 Missing
test/lib/init/ui/mock-ui.ts 88.75% ⚠️ 9 Missing
src/lib/init/preflight.ts 83.67% ⚠️ 8 Missing
src/commands/init.ts 70.00% ⚠️ 3 Missing
src/lib/init/git.ts 91.67% ⚠️ 1 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    75.87%    75.83%    -0.04%
==========================================
  Files          294       300        +6
  Lines        54454     54994      +540
  Branches         0         0         —
==========================================
+ Hits         41312     41702      +390
- Misses       13142     13292      +150
- Partials         0         0         —

Generated by Codecov Action

Comment thread test/lib/init/formatters.test.ts Fixed
@MathurAditya724 MathurAditya724 force-pushed the feat/init-wizard-ui-abstraction branch from 2b7b112 to f9457d9 Compare April 29, 2026 00:22
MathurAditya724 and others added 20 commits April 29, 2026 03:19
Introduces a thin `WizardUI` interface as the single I/O chokepoint for
the init wizard, with two implementations:

- `ClackUI` — wraps the current `@clack/prompts` calls (default for
  interactive runs; preserves existing visible behavior).
- `LoggingUI` — non-interactive impl for CI / `--yes` / non-TTY contexts.
  Plain stdout/stderr writes, no spinners, prompts throw
  `LoggingUIPromptError` so callers must pre-resolve choices.

`getUI()` factory selects an implementation based on `SENTRY_INIT_TUI`
env var, `--yes`, and stdin/stdout TTY state. The OpenTuiUI branch is
reserved for a follow-up PR.

This is PR 1 of a staged migration. No call sites change yet — the
factory returns `ClackUI` for interactive runs, so observable behavior
is unchanged. Subsequent PRs migrate `wizard-runner.ts`, `interactive.ts`,
`preflight.ts`, `formatters.ts`, and `git.ts` to call `ui.*`, then add
the OpenTUI implementation, then flip the default and remove clack.

Adds `@opentui/core` to devDependencies (dev-only; bundled into the
compiled binary in PR 3).

34 new unit tests for types, LoggingUI output routing/spinner/prompt
rejection/dispose, and factory runtime detection. Existing 191 init
tests continue to pass.
Replaces direct `@clack/prompts` calls with the `WizardUI` interface
across the init wizard. Functional behavior is unchanged because the
factory still returns `ClackUI` for interactive runs (which forwards
to clack under the hood) and `LoggingUI` for non-interactive contexts.

Migrated modules:

- `wizard-runner.ts` — constructs a single `WizardUI` via `getUI()`,
  passes it through `preamble()`, `resolveInitContext()`, and
  `handleSuspendedStep()`. Uses `ui.spinner()`, `ui.log`, `ui.intro`,
  `ui.cancel`, and `ui.confirm` instead of clack primitives. Cleans up
  via `await using ui = getUI(...)`.
- `interactive.ts` — accepts `ui` as a third arg; delegates select /
  multiselect / confirm to it.
- `preflight.ts` — accepts `ui` and routes org / project / team
  selection through it.
- `formatters.ts` — `formatResult` and `formatError` accept `ui` and
  call `ui.log.message`, `ui.outro`, `ui.cancel`. The `log.message`
  contract changed: implementations now own markdown rendering, so
  callers pass raw markdown rather than pre-rendered ANSI.
- `git.ts` — `checkGitStatus` accepts `ui` in its options bag.
- `clack-utils.ts` — `abortIfCancelled()` recognises both the unified
  `CANCELLED` sentinel from `ui/types.ts` and clack's legacy cancel
  symbol (the latter is kept for safety during the migration window).
  Return type changed to `Exclude<T, symbol>` so callers passing a
  union with a symbol member get the narrowed non-symbol type back.

Tests now construct a `MockUI` (new helper at `test/lib/init/ui/mock-ui.ts`)
that records every UI call and replays canned prompt responses, replacing
the previous `spyOn(clack, ...)` mocks. `wizard-runner.test.ts`
replaces `spyOn(initSpinner, "createWizardSpinner")` with
`spyOn(uiFactory, "getUI")` returning a MockUI whose `spinner()` is
the existing test spinner mock.

345/345 init/types/commands tests pass; typecheck clean; ultracite
clean; `check:deps` clean. PR 3 implements `OpenTuiUI`; PR 4 flips
the default and removes ClackUI.
Implements the full-screen OpenTUI WizardUI behind an opt-in `--tui`
flag, with stricli's auto-generated `--no-tui` as the escape hatch.

`OpenTuiUI` (`src/lib/init/ui/opentui-ui.ts`):

- Alternate-screen renderer via `createCliRenderer({ screenMode:
  "alternate-screen", exitOnCtrlC: false })`. Bypasses OpenTUI's
  built-in Ctrl+C handler so the wizard can resolve any pending prompt
  with `CANCELLED` and route exit through `wizard-runner.ts`'s
  cancellation path (which captures telemetry, etc.).
- Four-region vertical layout: header, scrollable log pane, single-line
  spinner block, prompt area.
- `spinner()` returns a handle that drives a periodic Text content
  update (4-frame cycle, matching `createWizardSpinner` cadence).
- `select` mounts a focused `SelectRenderable` and resolves on
  `itemSelected`.
- `multiselect` mounts a `SelectRenderable` with augmented `[x]`/
  `[ ]` labels and a custom global keypress handler: space toggles
  the highlighted option, enter confirms.
- `confirm` is a two-option select ("Yes"/"No") with the initial
  value mapped from the bool input.
- `Symbol.asyncDispose` calls `renderer.destroy()` to restore the
  main screen buffer on every exit path.

Factory (`src/lib/init/ui/factory.ts`) gains:

- `shouldUseOpenTui()` predicate (Bun runtime + opt-in + not legacy).
- New `getUIAsync()` that lazy-imports `@opentui/core` and
  constructs `OpenTuiUI` when the user opted in. Falls back to
  `ClackUI` if the import fails (e.g. accidental Node distribution
  invocation), so a missing native binding never crashes the wizard.
- The sync `getUI()` is preserved for non-TUI paths.

Wiring:

- `runWizard()` now calls `getUIAsync({ yes, preferTui, forceLegacy })`.
- `WizardOptions` extended with optional `tui` and `forceLegacyUi`.
- `sentry init` gains a `--tui` boolean flag; stricli auto-creates
  the `--no-tui` negation. `SENTRY_INIT_TUI=1` env var is the same
  opt-in for programmatic callers.
- `wizard-runner.test.ts` updated to spy on `getUIAsync` (was `getUI`).

Build / bundle:

- `script/bundle.ts` (npm/Node distribution) externalizes
  `@opentui/core` and `@opentui/core/*`. The lazy import in the
  factory throws under Node, which the factory catches and downgrades
  to `ClackUI` — no crash, no warning beyond the user not seeing the
  TUI.
- `script/build.ts` (Bun compile) bundles `@opentui/core` into the
  native binary alongside its Zig bindings. No script changes needed.

345/345 init/types/commands tests pass; typecheck, ultracite, and
`check:deps` all clean.

PR 4 will flip the default factory so the Bun binary uses
`OpenTuiUI` automatically (preserving `--no-tui` as escape hatch),
and remove `ClackUI` + `@clack/prompts`.
Flips the factory so interactive runs on the Bun-compiled binary use
`OpenTuiUI` automatically, then removes the `ClackUI` implementation
and the `@clack/prompts` dependency.

Selection rules (post-flip):

  1. `SENTRY_INIT_TUI=0` or `--no-tui` → `LoggingUI` (escape hatch)
  2. `--yes` / non-TTY               → `LoggingUI`
  3. Not running under Bun           → `LoggingUI` (npm/Node fallback)
  4. Default                         → `OpenTuiUI`

The `--tui` flag is still accepted but defaults to `true` — it's
now a synonym for the default behavior. `--no-tui` (auto-generated
by stricli's flag negation) flips it to `false` and is the user-
facing escape hatch.

Removed:
- `src/lib/init/ui/clack-ui.ts` — the `@clack/prompts` wrapper.
- `@clack/prompts` from `devDependencies`. `bun.lock` still pulls
  it in transitively via `ultracite` but it's no longer in our
  bundle graph.
- The `preferTui` plumbing in `UIFactoryOptions`. `forceLegacy`
  is now the only signal users / programmatic callers send.
- `tui` field from `WizardOptions`; replaced with the inverted
  `forceLegacyUi` derived from `flags.tui === false`.

`clack-utils.ts` no longer imports clack — `abortIfCancelled()`
recognises only the unified `CANCELLED` sentinel.

`test/lib/init/ui/factory.test.ts` rewritten to exercise
`getUIAsync` and the new selection rules. The test cases that
previously asserted `ClackUI` was returned now assert `LoggingUI`
under `--no-tui` / `SENTRY_INIT_TUI=0` / non-TTY paths, which
are the only branches reachable in tests (the OpenTUI path requires
a real renderer and is exercised manually).

344/344 init/types/commands tests pass; typecheck, ultracite, and
`check:deps` all clean. The npm/Node distribution continues to
exclude `@opentui/core` from its bundle (set up in PR 3) so users
on the npm package see `LoggingUI` (which throws on prompts —
matches the existing CI contract; non-interactive Node users should
pass `--yes`).
Two bugs that combined to make the OpenTUI path appear to do nothing
when users ran `sentry init` interactively:

1. **Stale VNode references.** The original code used the `Box()` /
   `Text()` factory functions and stored their return values to mutate
   later (`this.headerLine.content = x`). Those factories return
   `ProxiedVNode` proxies that queue calls into a `__pendingCalls`
   array; the calls only flush at instantiation time when the VNode
   is added to a parent. Subsequent mutations on the stored VNode
   reference never reach the live Renderable instance, so the screen
   stayed blank.

   Fix: use `BoxRenderable` / `TextRenderable` / `SelectRenderable`
   constructors directly. They take `(ctx, options)` and return live
   instances we can mutate in place. `renderer.root.ctx` is the
   shared RenderContext.

2. **Banner written to stderr bypassed the alternate-screen buffer.**
   `runWizard` was writing the ASCII banner with
   `process.stderr.write` before the wizard started. OpenTUI's
   alternate-screen takeover hides everything that wasn't routed
   through the renderer, so the banner was invisible and the user's
   first sight of the wizard was a blank screen.

   Fix: route the banner through `ui.log.message()` so the
   OpenTuiUI buffer captures it.

3. **Alternate-screen restore wiped all output on exit.** When the
   wizard finished and `[Symbol.asyncDispose]()` ran
   `renderer.destroy()`, the alternate-screen buffer was discarded
   and the user only saw a fraction of a second of content before
   the terminal returned to whatever was on the main screen before
   the wizard started.

   Fix: maintain a `transcript` array of every intro/log/outro line
   and replay it to stderr after `destroy()` so the wizard's output
   appears in scrollback like a normal CLI would. Stderr (rather
   than stdout) keeps progress chatter out of pipeable wizard
   output.

Verified manually with a small test harness that runs the renderer
in-process with forced `isTTY = true` and confirms the rendered
characters land in the output stream.

344/344 init/types/commands tests still pass; typecheck clean;
ultracite clean.
The first OpenTuiUI iteration rendered correctly but looked jagged:
ANSI escape codes in messages drew as literal characters, log lines
had ugly `info:` / `warn:` text prefixes, and the layout had no
visual chrome.

Visual changes:

- **Rounded border** around the entire wizard area in muted gray.
- **Gradient banner** rendered row-by-row inside the header, each
  row colored with the existing Sentry purple gradient palette
  (`#B4A4DE` → `#432B8A`).
- **Intro line** ("▸ sentry init") in accent purple, separated
  from the log pane by a thin top-bordered divider Box.
- **Iconified, color-coded log lines**:
    ●  light blue — info
    ▲  amber       — warn
    ✖  soft red    — error
    ✔  mint green  — success
  Two-cell row layout (icon | message) so the icon column never
  wraps into the message text.
- **Spinner** uses the accent purple for the live frames; on stop
  the row is promoted into the log pane with the matching success/
  warn/error icon and color.
- **Selects** get `textColor` / `selectedBackgroundColor` /
  `descriptionColor` props so the focused row is highlighted in
  accent purple instead of the default white-on-white.
- **Multiselect** uses ◉ / ◯ glyphs instead of `[x]` / `[ ]` and
  shows the keymap hint ("space toggle · enter confirm · esc
  cancel") in muted text under the prompt.

Implementation changes:

- **No more `renderInlineMarkdown` for OpenTUI content**. OpenTUI's
  TextRenderable treats the `content` string as opaque — embedded
  ANSI escape codes from the markdown renderer were drawn as literal
  characters, causing the "jagged" look. We now `stripAnsi` every
  incoming message and apply colors via the `fg` prop on dedicated
  TextRenderables (one for the icon, one for the text).
- **`WizardUI.banner(art)` method**. Banner rendering is now
  delegated to the implementation:
    - `OpenTuiUI.banner()` is a no-op — the alternate-screen header
      already paints the banner in the gradient.
    - `LoggingUI.banner()` writes the pre-styled ANSI string to
      stderr (preserving the legacy CI behaviour exactly).
  `runWizard` calls `ui.banner(formatBanner())` once before
  `ui.intro`. Previously routing it through `ui.log.message` forced
  OpenTuiUI to embed the ANSI banner string into its log pane, which
  broke rendering.
- `MockUI` records `banner` calls so existing tests keep passing
  and future tests can assert on banner ordering.

344/344 init/types/commands tests pass; typecheck, ultracite, and
`check:deps` all clean. Verified visually via an in-process test
harness — output is now structured, colored, and aligned.
…ed summary

Three changes triggered the rewrite:

1. **Multiselect toggle was broken.** The imperative version called
   `SelectRenderable.setOptions()` from inside a global keypress
   handler. The renderable's internal `selectedIndex` was mutable
   state read on each space-press, and reading it could lag the
   visible highlight by one frame on fast keyboards — toggles landed
   on the wrong row or were silently dropped.

2. **No place to surface Sentry product facts.** The user asked for
   a panel that helps onboarding users learn what they get out of
   Sentry beyond the wizard itself.

3. **The completion summary leaked markdown.** `formatResult`
   built terminal-flavored markdown (color tags, an aligned KV
   table, a tree of changed files) and pushed it through
   `ui.log.message`. `OpenTuiUI`'s TextRenderable can't parse
   markdown — it strips ANSI, leaving literal `<yellow>~</yellow>`
   tags and pipe-cells in the visible output.

## Architecture

The OpenTuiUI class is now a thin imperative bridge that mutates a
`WizardStore` (`src/lib/init/ui/opentui-store.ts`). The store is a
minimal external store with the React 18+ `useSyncExternalStore`
contract — listeners are notified on every snapshot replacement.

The React tree (`src/lib/init/ui/opentui-app.tsx`) subscribes via
`useSyncExternalStore` and renders the layout declaratively:

  ┌─ Sentry init ──────────────────────────────────────────────┐
  │  banner (gradient, 6 rows)         │ Did you know?         │
  │  ▸ sentry init                     │ <tip title>           │
  │  ─────────                          │ <tip body, wrapped>   │
  │  ●  log line                        │                       │
  │  ▲  log line                        │ Tip 3 of 12           │
  │  ◒  spinner...                      │                       │
  │  Summary panel (after completion)  │                       │
  │  Prompt area (transient)           │                       │
  └────────────────────────────────────────────────────────────┘

The MultiSelectPrompt component owns its own selected-set state via
`useState` and uses `useKeyboard` (from `@opentui/react`) for
space/enter handling. React's render cycle guarantees the `[◉]` /
`[◯]` markers always reflect the current toggle state.

The Sidebar rotates through 12 curated tips (`sentry-tips.ts`)
covering errors↔traces, replay, tracing, alerts, releases, source
maps, crons, user feedback, profiling, AI monitoring, Seer, and
self-hosted. The OpenTuiUI bridge ticks the rotation index every 8s.

## New `WizardUI.summary()` method

Added `summary(WizardSummary)` to the WizardUI interface for the
completion panel. `WizardSummary` is structured data
(`{ fields: [{label, value}], changedFiles?: [{action, path}] }`)
not pre-rendered markdown.

  - `OpenTuiUI` mounts the new `SummaryPanel` React component:
    a top-bordered box with right-aligned label cells and a flat
    changed-files list (one per row, colored `+`/`~`/`−` glyph).
  - `LoggingUI.summary()` writes the same data as a compact
    two-column listing to stdout, matching the rest of the
    non-interactive output style.

`formatters.ts` now builds the structured summary and calls
`ui.summary()` instead of pushing markdown through
`ui.log.message`.

## Imports / build

- Added `react@^19`, `@opentui/react@^0.2`, `@types/react` as
  devDependencies (bundled into the Bun binary, externalized from
  the npm/Node distribution).
- `tsconfig.json` enables `jsx: "react-jsx"` with
  `jsxImportSource: "@opentui/react"` so JSX intrinsics
  (`<box>`, `<text>`, `<select>`) typecheck against the
  OpenTUI element types.
- `script/bundle.ts` externalizes `@opentui/react` and `react`
  alongside `@opentui/core` so the npm bundle stays Node-only.
- `createOpenTuiUI()` serializes its dynamic imports because the
  `@opentui/react` chunk re-exports core primitives and parallel
  imports tripped a TDZ inside their build.

## Tests

`MockUI` records `summary` calls. The `formatters.test.ts`
tests now assert on the structured `WizardSummary` shape rather
than markdown strings — same coverage, looking at the actual
contract instead of an intermediate rendering format.

346/346 tests pass; typecheck, ultracite, and `check:deps` clean.
Verified visually with an in-process test harness that mounts the
React app, exercises every WizardUI method, and dumps the
post-dispose transcript.
The Sentry tips sidebar is fixed at 36 columns, plus the banner takes
\~55 cols and the wizard chrome eats another 6 (border + padding +
gap), so anything under \~100 columns wrapped the layout awkwardly.

Hide the sidebar entirely below `SIDEBAR_BREAKPOINT` (100 cols) and
let the main column take the full row width. `useTerminalDimensions`
re-renders on resize, so dragging a window between widths flips the
layout live.

The two related constants — `SIDEBAR_WIDTH` (used as the
renderable's `width` prop) and `SIDEBAR_BREAKPOINT` (used to
gate visibility) — both live next to the App component now so the
breakpoint reasoning is in one place.
Three small changes that came out of user feedback on real init runs:

1. **Box title is lowercase `sentry init`.** Matches the command name
   the user typed; the previous capitalised "Sentry init" felt off.

2. **Drop the `▸ sentry init` line under the banner.** The banner art
   already reads "SENTRY" and the box's top-border title says
   `sentry init` — repeating it in a third place underneath was
   redundant. `OpenTuiUI.intro()` is now a no-op for this reason;
   `LoggingUI` keeps it (the shell prompt provides no equivalent).

3. **Replace the transcript replay with a focused completion report.**
   Earlier the bridge accumulated every log/intro/spinner-stop line
   and replayed them all to stderr after `renderer.destroy()`. That
   produced a noisy wall on success:

       ▸ sentry init
       ● This wizard uses AI to analyze your project ...
       For manual setup: https://docs.sentry.io/...
       ✔ Using existing project ...
       ✔ Selecting features
       ✔ Done
         <markdown table>
       ● Please review the changes above before committing.
       ● You're one of the first to try the new setup wizard! ...
       ✔ Sentry SDK installed successfully!

   The bridge now keeps just two pieces of state — `outroMessage`
   (set by `outro()`) and `failureMessage` (set by `cancel()`) —
   plus the structured summary on the store. On dispose it composes
   one of three shapes:

   - **Success:** outro line + summary fields + changed files
   - **Failure:** the cancel/error line on its own
   - **Empty:**   nothing to print (early abort), skip stderr write

   For a real run that emits the example output above the user
   ends up with this in their scrollback:

       ✔  Sentry SDK installed successfully!

          Platform   sentry.javascript.tanstackstart-react
          Directory  /Users/am/dev/sentry/cli-test
          Features   Error Monitoring, Tracing, Replay, Profiling, Logging
          Commands   bun add @sentry/tanstackstart-react
          Project    https://test101-n4.sentry.io/...
          Docs       https://docs.sentry.io/...

          Changed files
            ~ src/router.tsx
            + instrument.server.mjs
            ...

   The intermediate spinner stops, the AI disclaimer, and the two
   "please review" / "first to try" info lines all stay live on
   the alternate-screen log pane during the run but don't make the
   scrollback report.

346/346 tests pass; typecheck, ultracite, and check:deps clean.
CI was failing on two checks:

1. **Lint & Typecheck** — `bun run lint` reported 46
   `useHookAtTopLevel` errors against test helpers like
   `useTestConfigDir` and `useEnvSandbox` (and a handful of
   non-React `use*` helpers in `src/`). Biome's React-hook
   rules infer hook-ness from the `use*` naming convention; once
   the previous PR enabled JSX in `tsconfig.json` the rule started
   linting every file. Add an override in `biome.jsonc` that
   disables `useHookAtTopLevel` and `useExhaustiveDependencies`
   for everything except `src/**/*.tsx` (the React tree). The
   actual React component file (`src/lib/init/ui/opentui-app.tsx`)
   still gets the rule.

2. **CodeQL** flagged `infos.some((s) => s.includes("https://..."))`
   in `test/lib/init/formatters.test.ts` as "incomplete URL
   substring sanitization". The string-includes match is harmless
   in a test but the rule pattern is conservative. Switch to
   regex-extracting URLs from the info messages and asserting via
   `toContain` against the resulting array — explicit URL
   intent, no substring ambiguity.

`bun run lint` now exits 0 (one pre-existing unused-suppression
warning in `src/lib/formatters/markdown.ts` from #462 — not blocking).
6248/6248 unit tests pass; typecheck clean; check:deps clean.
Three small visual improvements that came out of dogfooding:

1. **Experimental confirm prompt** drops the all-caps "EXPERIMENTAL:"
   prefix in favor of "Ready to set up Sentry? The wizard will edit
   files in this directory." The tone shift is the point — the old
   message read like a warning the user had to dismiss; the new one
   reads like a sanity check before a tool does work. `initialValue:
   true` keeps the default answer the same.

2. **Multiselect** swaps the radio-glyph markers (`◉` / `◯`) for
   bracketed checkboxes (`[✔]` / `[ ]`) — universally readable
   and the brackets give a stable alignment column. The hint line
   gains an at-a-glance counter ("3/5 selected" in accent purple)
   so the user knows where they stand without scrolling the option
   list.

3. **Post-dispose scrollback report** is now colored. Previously it
   was plain ASCII — outro line, field labels, changed-file glyphs
   all in default foreground. Now via chalk:

     ✔   (green)   Sentry SDK installed successfully!  (bold)

       Platform   (muted)   sentry.javascript.tanstackstart-react
       Directory  (muted)   /Users/am/dev/sentry/cli-test
       …

       Changed files  (muted bold)
         +  (green)   instrument.server.mjs
         ~  (yellow)  src/router.tsx
         −  (red)     old-file.ts

   Failure path: `✖` and the cancel/error message both colored
   red. Brand palette mirrors the live OpenTUI screen so the
   handoff to scrollback feels intentional.

   Chalk auto-detects color support — on TTYs with truecolor the
   exact brand hex codes render; on basic terminals it falls back
   to the nearest 16-color match; on non-TTYs (CI, piped output) it
   emits no escape codes. No behavior change for any of those paths
   beyond the new icons/labels themselves.

Lint, typecheck, 6248/6248 unit tests, and check:deps all clean.
After comparing the new flow against the original `@clack/prompts`
flow, the only behavior gap was the experimental confirm — and the
gap was UX, not function. The previous `ui.confirm` displayed a
single yes/no question; "no" was technically right but ambiguous
about what would happen next.

Switch to `ui.select<"continue" | "exit">` so each branch
carries an explicit, muted hint:

  ▶ Yes, continue
    wizard will detect your stack and apply changes
    No, exit
    exits without making any changes

This makes the cancel path obvious without relying on tone in the
question itself. The post-dispose report still shows
`✖  Setup cancelled.` (red) when the user picks "No, exit".

Two related fixes:

1. **Select/multiselect height arithmetic.** OpenTUI's
   `SelectRenderable` allocates 2 rows per option when
   `showDescription` is on (label + hint), 1 row otherwise. The
   previous `Math.min(prompt.options.length + 1, 8)` only counted
   the label rows, so options with hints clipped behind the scroll.
   Detect whether any option has a hint, set
   `linesPerItem = hasDescriptions ? 2 : 1`, and size the
   renderable to `visibleItems * linesPerItem`.

2. **Conditional `showDescription`.** When no option carries a
   hint we now pass `showDescription={false}`, which gives plain
   single-line rows for confirmation-style prompts (e.g. the team
   ambiguity prompt). Previously every Select reserved row space
   for an empty description.

Beyond the experimental prompt, comparing the old flow line by
line confirmed:

- Banner — old went straight to stderr, new goes through
  `ui.banner()` which is a no-op on OpenTuiUI (header paints
  it directly) and writes to stderr on LoggingUI. Parity preserved.
- Intro / outro — old used `clack.intro`/`outro` framing, new
  uses the box title + a green `✔` outro line.
- All log severities (info / warn / error / success / message)
  are routed through `ui.log.*` and rendered with the same
  glyphs the old clack flow used (●, ▲, ✖, ✔).
- Cancel paths from preflight, git checks, and prompt cancellations
  all hit `ui.cancel` → red `✖` line in the post-dispose report.
- Dry-run warning, AI disclaimer, feedback prompt, docs link —
  all preserved as live log lines (intentionally omitted from the
  post-dispose scrollback report to keep the success summary
  compact, per earlier feedback).

Lint, typecheck, 6248/6248 unit tests, and check:deps all clean.
… panel

Two improvements that came from comparing the new TUI flow to the
behavior the old clack flow had.

## Tree view for changed files

The original `@clack/prompts` formatter rendered changed files as a
nested directory tree (`├─ src/`, `│  ├─ app/`, …). The first
React iteration flattened it to a one-line-per-file list, which
worked but lost the visual grouping that made big patches readable
at a glance.

New `src/lib/init/ui/file-tree.ts` exposes:
- `buildFileTree(files)` — collapses common prefixes; sorts dirs
  before files within each level (alphabetical thereafter).
- `flattenTree(root)` — emits one `FileTreeRow` per visible
  line with the box-drawing prefix already computed.

Both `OpenTuiUI` (live React panel + post-dispose stderr report)
and `LoggingUI` (CI stdout summary) consume the same tree shape
and color it according to their renderer:

  Changed files
  ├─ src/
  │  ├─ app/
  │  │  ├─ + instrumentation-client.ts
  │  │  └─ ~ layout.tsx
  │  ├─ ~ router.tsx
  │  └─ + server.ts
  └─ ~ vite.config.ts

In the React panel and the chalk-colored stderr report:
- Box-drawing branches in muted gray
- `+` create in green, `~` modify in yellow, `−` delete in red
- File / directory labels in foreground

`LoggingUI` ships the same tree shape as plain ASCII so CI logs
keep the structure without ANSI escapes.

## 'Files analyzed' sidebar panel

Old flow: every `read-files` tool call updated the spinner with
a multi-line "Reading files…" tree, then the next tool overwrote
it within ~half a second. Users couldn't tell what context the AI
had looked at.

New flow: a persistent `<FilesAnalyzedPanel>` in the right
sidebar that accumulates every read across the entire session.
Each row shows a status icon — yellow `●` while reading, green
`✔` once analyzed — plus the file basename. A counter at the top
(`3/5 read`) gives a quick health-check; a `+ N more` line
hides anything beyond the visible 10 rows so the panel doesn't
push the tips off-screen.

Plumbing:
- New optional `recordFilesReading(paths)` and
  `markFilesAnalyzed(paths)` methods on `WizardUI`. Optional so
  `LoggingUI` can leave them undefined (the spinner-message
  approach there already lands as separate log lines and works
  fine in non-interactive contexts).
- `OpenTuiUI` implements them by mutating the store; React's
  `useSyncExternalStore` re-renders the panel.
- `wizard-runner.ts` calls them around `executeTool()` for
  `read-files` operations only — list-dir / file-exists-batch
  pass through unchanged.
- The store dedupes by path: re-reading the same file in a later
  batch keeps the entry but doesn't downgrade an `analyzed`
  status back to `reading`.

The sidebar still hides on terminals narrower than 100 columns
(per `SIDEBAR_BREAKPOINT`); on those the read-files tree still
flashes in the spinner the same way it did before.

Lint, typecheck, 6248/6248 unit tests, check:deps all clean.
… line

The bordered 'Files analyzed' panel reserved up to 13 sidebar rows
with flexShrink={0}, which pushed the 'Did you know?' tip card
off-screen on shorter terminals. Hoist file-read activity into a
single-line indicator above the spinner instead, freeing the entire
sidebar height for tips. Inspired by PostHog's wizard, which avoids
unbounded per-file lists in favor of bounded status indicators.
…dler bug

CI was failing on `Build Binary (linux-x64)` and
`Build Binary (linux-x64-musl)`. Two underlying problems:

  1. `@opentui/core` ships Bun-specific
     `import "..." with { type: "file" }` syntax for
     tree-sitter assets (*.scm, *.wasm) that esbuild can't parse.

  2. Even if we externalize OpenTUI, Bun.compile mangles React's
     CJS `jsx-runtime` when it's reached through static imports
     bundled inside `__commonJS` scope — produces malformed
     output with a TDZ `init_react` symbol that crashes the
     binary at startup with a SyntaxError.

Both issues converge on: don't let the bundlers (esbuild OR
Bun.compile) statically resolve React/OpenTUI inside the bundled
graph. The fix has three pieces:

**** adds a Bun-specific
`with { type: "file" }` import for the local React tree:

    import opentuiAppPath from "./opentui-app.tsx" with { type: "file" };

At compile time Bun copies the .tsx bytes into the binary's
virtual filesystem and replaces the import with a runtime path
string. The factory then `await import(opentuiAppPath)`s that
path — Bun's runtime (not its bundler) resolves React +
`@opentui/react` fresh, outside the buggy bundler path. The
trade-off is a small first-invocation parse overhead.

**** is extended to handle the
`file` attribute alongside the existing `text` one. For
`type: "file"` the plugin copies the source into the bundle's
output directory and marks the import external so esbuild leaves
the original `with { type: "file" }` clause intact for
Bun.compile to pick up downstream.

**** externalizes the entire OpenTUI + React stack
from esbuild (`@opentui/core`, `@opentui/core/*`,
`@opentui/react`, `@opentui/react/*`, `react`, `react/*`)
and adds the sidecar `dist-bin/opentui-app.tsx` to the cleanup
step so it doesn't ship as a release artifact.

Verified locally: `./dist-bin/sentry-linux-x64 --version` returns
correctly, `init --yes` runs through to summary. 6248/6248 unit
tests pass; typecheck, ultracite, and `check:deps` all clean.
The `@ts-expect-error` on the new import gets auto-removed once
`@types/bun` ships a declaration for the `with { type: "file" }`
attribute.
The previous `text-import-plugin` extension assumed the bundle's
`outdir`/`outfile` directory already existed when the
`with { type: "file" }` resolution fired. That's true for the
binary build (`script/build.ts` mkdirs `dist-bin/` early), but
the npm bundle (`script/bundle.ts`) lets esbuild create `dist/`
on output write — which happens after the plugin tries to copy.

Result: CI's `Build npm Package` jobs failed with

    text-import-plugin: failed to copy
      …/src/lib/init/ui/opentui-app.tsx → …/dist/opentui-app.tsx:
      ENOENT: no such file or directory

Fix: `mkdirSync(outdir, { recursive: true })` before
`copyFileSync`. Idempotent and cheap.

Also tidy the npm bundle cleanup to remove the stray sidecar
`dist/opentui-app.tsx` that the plugin produces. The npm
distribution gates OpenTuiUI to the Bun binary so the sidecar is
never read at runtime, and `package.json#files` already excludes
it from the published tarball — but having it sitting in `dist/`
locally is just clutter.
The static `with { type: "file" }` import of `opentui-app.tsx` and
the dynamic `await import(opentuiAppPath)` in `createOpenTuiUI`
resolve to the same absolute path, which Bun's module loader treats
as a single cache entry. The first lookup populates the cache with
a synthetic `{ __esModule, default: undefined }` shape (the
file-resource representation), so the dynamic import returns that
shape instead of evaluating the .tsx, leaving `app.App === undefined`.
React's reconciler then throws "Element type is invalid".

Adding a `?bridge=1` query string to the dynamic import specifier
gives Bun a distinct cache key while resolving to the same on-disk
file. The .tsx evaluates normally and `App` is exported as expected.
The OpenTUI sidebar previously hosted a single 'Did you know?' tip
panel; everything else (file-read activity, workflow progress) was
either ephemeral (spinner messages) or absent (no progress
indicator). This adds two new sidebar panels stacked below the tip
card, giving users a richer at-a-glance view of what the wizard is
doing without changing the main column or the imperative
`WizardUI` surface in any breaking way.

Sidebar layout (top-to-bottom, on terminals \u2265 100 cols):

  1. Did you know? \u2014 unchanged, fixed at 12 rows so it can never
     be squashed off-screen by content below.
  2. Progress (n/total) \u2014 static checklist of nine canonical
     workflow steps. Rows transition pending \u2192 in_progress \u2192
     completed (or skipped, or failed) in place. The
     'select-target-app', 'resolve-dir', and 'check-existing-sentry'
     plumbing steps are intentionally excluded from the visible
     allowlist so the panel stays compact.
  3. Files analyzed (n/total) \u2014 scrollable directory tree of
     every file the wizard has read. Built on OpenTUI's `<scrollbox>`
     with sticky-bottom tracking so newly-read files always come
     into view, like a tail -f. Active reads show '\u25d0 file.ts' in
     accent purple; analyzed files dim to muted-green '\u2713 file.ts'.
     Hidden until at least one file has been recorded \u2014 no empty
     box during the auth/discover phase.

On narrow terminals (< 100 cols) the entire sidebar is hidden as
before; the inline 'Reading X, Y \u2026 (n/m analyzed)' line in the
main column takes over the file-read indicator role. The Sidebar
component owns the breakpoint check via the `showFileReadInline`
prop on MainColumn so the responsive switch stays in one place.

Implementation details:

- `WizardUI` gains an optional `setStep(stepId, status)` method.
  `LoggingUI` leaves it undefined; the running log already narrates
  progress for the non-interactive path. `OpenTuiUI` translates each
  call into a `WizardStore` mutation.

- The wizard runner threads step transitions through the
  suspend/resume loop via a single `activeStepId` cursor. A step is
  marked `in_progress` on first suspend (idempotent for multi-suspend
  read-files \u2192 analyze sequences); the previous step flips to
  `completed` when `stepId` changes; the active step flips to
  `failed` on the catch path before the wizard tears down.

- The store implements implicit skip back-fill: when a step
  transitions to `in_progress`, any earlier `pending` step
  (per the new `CANONICAL_STEP_ORDER` constant) is back-filled to
  `skipped`. The workflow can only move forward, so an earlier
  pending step that the runner walked past was bypassed by an
  if-branch \u2014 no need for the runner to announce skips explicitly.

- The store's mutators preserve array reference equality on
  no-op transitions so `useSyncExternalStore` doesn't trigger
  spurious React re-renders. A unit test verifies idempotency of
  `setStepStatus` for the multi-suspend case.

- The shared `buildReadTree` helper in `file-tree.ts` mirrors
  `buildFileTree` (changed-files) but tags leaves with read
  status instead of change action and preserves insertion order
  (no sort) so sticky-bottom scrollbox tracking works as
  expected. `FileTreeRow` gains an optional `status` field
  alongside `action`.

- Fragment-shortened sidebar labels live in `STEP_LABELS_SHORT`
  next to the existing full `STEP_LABELS`, picked via
  `shortStepLabel(id)`. The full labels stay the source of truth
  for the spinner message in the main column.

Tests:

- New `test/lib/init/ui/file-tree.test.ts` covers the
  `buildReadTree` builder: empty input, nesting, status
  propagation, insertion-order preservation, no collisions with
  the sorted changed-files builder, and dedup of intermediate
  directories.

- New `test/lib/init/ui/opentui-store.test.ts` covers the step
  state machine: pre-population, idempotent re-entry,
  back-fill behaviour, allowlist filtering, completed/failed
  precedence, skip clobber-protection, and subscriber
  notification semantics.

- `MockUI` records `recordFilesReading`, `markFilesAnalyzed`, and
  `setStep` calls so wizard-runner tests can assert on them
  without coupling to a concrete UI.

Verification:
- bun run typecheck (clean)
- bun x biome check src/ test/ (1 pre-existing warning, no new ones)
- bun test test/lib/init/ (208 pass, was 192 \u2014 16 new tests)
- SENTRY_CLIENT_ID=test bun run build (binary 118.24 MB, +0.01 MB)
- SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, +1.7 KB)
- ./dist-bin/sentry-linux-x64 init --help (renders cleanly)
- Smoke test creating an OpenTuiUI and exercising recordFilesReading,
  markFilesAnalyzed, setStep, spinner, summary, and dispose paths
  produced no React reconciler errors.
Surfaced by Biome's noUnusedImports rule after rebasing onto latest
main. Three init files still imported helpers from clack-plain.js
that haven't been used since the migration to WizardUI; the rebase
auto-merge left them in place because they didn't conflict
literally even though the call sites were rewritten.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants