feat(init): scaffold WizardUI abstraction layer for OpenTUI migration#862
Draft
MathurAditya724 wants to merge 20 commits intomainfrom
Draft
feat(init): scaffold WizardUI abstraction layer for OpenTUI migration#862MathurAditya724 wants to merge 20 commits intomainfrom
MathurAditya724 wants to merge 20 commits intomainfrom
Conversation
Contributor
|
Contributor
Codecov Results 📊✅ 6341 passed | Total: 6341 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
All tests are passing successfully. ❌ Patch coverage is 68.45%. Project has 13292 uncovered lines. Files with missing lines (9)
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 |
2b7b112 to
f9457d9
Compare
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.
4a3e835 to
40741b7
Compare
This was referenced Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 1 of a staged migration that replaces
@clack/promptsinsentry initwith OpenTUI, a full-screen TUI
library. This PR introduces the abstraction layer only — no observable
behavior changes.
Inspired by PostHog wizard's
WizardUIpattern (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:types.tsWizardUIinterface,SpinnerHandle, sharedCANCELLEDsentinel +isCancelled()guardlogging-ui.tsLoggingUIPromptErrorclack-ui.ts@clack/promptscallsfactory.tsgetUI()selects impl based onSENTRY_INIT_TUIenv var,--yes, and stdin/stdout TTY stateAdds
@opentui/core@^0.2.0todevDependencies(dev-only; bundled intothe compiled binary in PR 3).
bun run check:depscontinues to pass.What's NOT in this PR
wizard-runner.ts,interactive.ts,preflight.ts,formatters.ts, andgit.tsstill import from@clack/promptsdirectly. PR 2 migrates them to callui.*.OpenTuiUIlands in PR 3 behind a--tuiflag. PR 4 flips the default and removes clack.
Roadmap
LoggingUI+ClackUI+ factory + testsWizardUI(still defaults toClackUI)OpenTuiUI(Bun-binary only); gate behind--tuiflagOpenTuiUI; eventually remove@clack/promptsandClackUISelection rules (factory.ts)
The Node distribution will keep using a non-OpenTUI implementation
because OpenTUI is Bun-only; PR 3 adds the runtime-detection branch.
Testing
spinner lifecycle, prompt rejection with descriptive errors,
`await using` dispose behavior
`SENTRY_INIT_TUI`, and `forceLegacy`
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.