feat(init): replace OpenTUI with Ink for the wizard UI#885
feat(init): replace OpenTUI with Ink for the wizard UI#885MathurAditya724 wants to merge 68 commits intomainfrom
Conversation
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.
Swaps the OpenTUI implementation introduced in PR 4 for an Ink-based
one. Same WizardUI surface, same store/store-mutators/file-tree, same
sidebar layout (tip card + progress checklist + files-read tree) —
just different render primitives.
Why Ink?
- No native bindings. OpenTUI's renderer is Zig-compiled and
shipped as ~4.5 MB of platform-specific .so/.dylib/.dll files
loaded via Bun's bun:ffi. The compiled CLI binary inlined that
plus a ~6 MB JS bindings layer, costing ~10.7 MB. Ink is pure
JS + React, dropping the binary by ~9.4 MB (118.23 → 108.79 MB).
- No alternate-screen flicker. OpenTUI took over the whole
terminal via the alternate-screen buffer; on dispose it wiped
every trace of the run. We had to replay a stripped-down
transcript to stderr so users had any scrollback. Ink renders
inline, so log lines accumulate naturally and the user keeps
everything in their terminal history.
- Mature ecosystem. ink-spinner, ink-select-input, etc. cover
most of what we hand-rolled in OpenTUI. Used by Wrangler,
Gatsby, GitHub Copilot CLI, and others.
Things that stayed the same:
- WizardUI interface (banner / intro / log / spinner / select /
multiselect / confirm / summary / cancel / outro / setStep /
recordFilesReading / markFilesAnalyzed)
- The external WizardStore + useSyncExternalStore subscription
pattern (renamed from opentui-store.ts to wizard-store.ts)
- file-tree.ts, sentry-tips.ts, types.ts (unchanged)
- Sidebar layout: tip card (fixed 12 rows) on top, step
checklist in the middle, files-read tree on the bottom
- Step progress checklist with implicit-skip back-fill
- Post-dispose chalk summary echoed to stderr after Ink unmounts
Things that changed:
- Sidebar tree window vs. scrollbox. Ink doesn't ship a
scrollbox primitive. The files-read panel now shows the *last*
N rows that fit, with a "… N earlier" hint when truncated. The
tail-f UX (newly-read files always visible) comes for free
since the panel re-renders to the bottom.
- Multi-select. Built directly on Ink's useInput. ink-select-
input doesn't expose a way to draw bracketed [✔] markers in
addition to the cursor.
- Cancellation. OpenTUI's keyHandler is global; Ink's useInput
is per-component. Cancellation now hooks into process-level
SIGINT (Ink's exitOnCtrlC: false lets us route Ctrl+C through
our cooperative-cancel path instead of yanking the process).
Bun-binary-only (same as OpenTUI was):
- Ink's reconciler and yoga-layout use top-level await, which
esbuild can't emit in our CJS npm bundle. So Ink is bundled
into the Bun binary via the with-file import trick (same as
OpenTUI used) but excluded from dist/index.cjs entirely. Node
users continue to get LoggingUI — unchanged from before.
- This preserves AGENTS.md's "no runtime dependencies" rule.
bun run check:deps passes.
Bun.compile workarounds (carried over from the OpenTUI fix in this
PR series):
- The with-file import keeps ink-app.tsx out of esbuild and
Bun.compile's static bundle graph. Without this, Bun.compile
mangles Ink's and React's CJS dev wrappers (it injects
__promiseAll runtime helpers in positions the IIFEs can't
parse, producing "SyntaxError: Unexpected identifier
'__promiseAll'" at startup inside e.g. parse-keypress.js or
react-jsx-runtime.development.js).
- ?bridge=1 query string on the dynamic import bypasses Bun's
module-cache collision between the file-resource import and
the dynamic import of the same absolute path. Same workaround
we landed earlier for OpenTUI.
- define process.env.NODE_ENV=production on Bun.build forces
React to use its production builds; the dev builds otherwise
trigger the __promiseAll bug even via the embedded-file path.
- react-devtools-core installed as a devDep so Bun.compile can
resolve the static reference inside Ink's reconciler. The
actual import is gated behind process.env.DEV === "true" so
it's dead code in production.
Files added:
- src/lib/init/ui/ink-app.tsx — Ink React tree (renamed from
opentui-app.tsx, fully rewritten for Ink primitives)
- src/lib/init/ui/ink-ui.ts — InkUI bridge class (renamed from
opentui-ui.ts, ported to Ink's render() API)
Files renamed:
- src/lib/init/ui/opentui-store.ts → wizard-store.ts (no logic
changes — just docstring updates removing OpenTUI references)
- test/lib/init/ui/opentui-store.test.ts → wizard-store.test.ts
Files deleted:
- src/lib/init/ui/opentui-app.tsx
- src/lib/init/ui/opentui-ui.ts
Dep changes:
- REMOVED: @opentui/core, @opentui/react
- ADDED: ink, ink-spinner, ink-select-input, ink-text-input,
react-devtools-core (all devDependencies)
Verification:
- bun run typecheck (clean)
- bun x ultracite check (1 pre-existing warning, no new ones)
- bun test --isolate test/lib/init/ (227 pass)
- bun run check:deps (no runtime dependencies)
- SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
-9.44 MB vs. OpenTUI's 118.23 MB)
- SENTRY_CLIENT_ID=test bun run bundle (npm 3.29 MB, unchanged)
- ./dist-bin/sentry-linux-x64 init --help (renders cleanly)
- node ./dist/bin.cjs init --help (Node path renders cleanly)
- Smoke test creating an InkUI and exercising every WizardUI
method produced no React reconciler errors and a clean
post-dispose summary.
Replaces `ink-select-input` with a hand-rolled select prompt built
on Ink's `useInput` hook directly. Same pattern as our existing
`MultiSelectPrompt` — same cursor glyph, same accent color, same
hint placement, same keyboard handling.
Why? `ink-select-input`'s items array is recreated on every parent
render, which races with its internal `useEffect` that resets
`selectedIndex` on items-change. Under our store-driven re-render
cadence (tip rotation every 8s, log lines, file-read updates) the
cursor never settled and arrow keys felt unresponsive — the user
reported the experimental-confirm prompt couldn't be navigated or
selected.
Doing the same `useInput`-based render that `MultiSelectPrompt`
already uses gives us:
- Stable state across re-renders (cursor lives in our own
`useState`, no externally-driven reset).
- Consistent visual styling between single- and multi-select.
- Escape-to-cancel handling. The bridge translates `resolve(null)`
to the shared `CANCELLED` sentinel, so the wizard runner's
cancellation path triggers cleanly.
Also drops `ink-select-input` and `ink-text-input` from devDeps
(both unused now) and updates the build/bundle externals lists.
Verification:
- bun run typecheck (clean)
- bun x ultracite check (1 pre-existing warning, no new ones)
- bun test --isolate test/lib/init/ (227 pass)
- SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
no size regression)
- InkUI smoke test renders cleanly through the dispose path.
Root cause: a known Bun + Ink interaction bug (oven-sh/bun#6862, vadimdemedes/ink#636, both still open). Ink's `useInput` hook listens for `readable` events on its stdin (default `process.stdin`) and pulls bytes via `stdin.read()`. Bun's compiled binaries have a long-standing issue where the inherited fd 0 accepts `setRawMode(true)` but never delivers `readable` events for terminal input. So: - the wizard rendered fine (Ink's stdout writes are unaffected), - but arrow keys, Enter, and Ctrl+C all did nothing — `useInput` listeners never fired, - and "can't exit the program" because raw mode suppresses SIGINT delivery for Ctrl+C, and our SIGINT fallback handler never ran either. Fix: open a fresh `/dev/tty` `tty.ReadStream` ourselves and pass it to Ink as the `stdin` option. Fresh fds opened from inside the process don't trigger the inheritance bug, so their `readable` events fire correctly. Ink's `setRawMode(true)` on the fresh stream toggles termios on the underlying TTY device — the same device fd 0 points at — so the user's terminal still goes raw, just via a different fd. We close the stream on dispose to release the libuv handle. Bonus fixes wrapped in: 1. **Ctrl+C handling in raw mode.** Each prompt's `useInput` now treats `key.ctrl && input === "c"` as a cancel (same path as Esc). A top-level `useInput` in the App component handles Ctrl+C during spinners (no prompt mounted) by calling `process.exit(130)` so users can always abort. 2. **Removed dead `forwardFreshTtyToStdin()` call.** The macOS-only workaround in `wizard-runner.ts` was clack-era dead code: `LoggingUI` doesn't read stdin (its prompts throw), and `InkUI` now opens its own /dev/tty. The function is preserved in `stdin-reopen.ts` for future callers but no longer wired in. This also removes a class of conflicts where the workaround's no-op `_read` and data-event forwarding actively broke Ink's stdin reading on macOS. 3. **Stdin teardown.** `InkUI.[Symbol.asyncDispose]` now calls `setRawMode(false)` and `destroy()` on the fresh stream so the user's shell isn't left in raw mode if the wizard crashes mid-prompt. Verification: - bun run typecheck (clean) - bun x ultracite check (1 pre-existing warning, no new ones) - bun test --isolate test/lib/init/ (227 pass) - SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB, no size regression) - Binary smoke (init --help) renders cleanly. - Embedded ink-app.tsx + new openFreshTtyForInk helper visible in compiled binary's strings dump. Caveats this fix carries forward: - Still requires `react-devtools-core` as a devDep so Bun.compile can resolve Ink's static reference (gated behind `process.env.DEV === "true"` at runtime, dead code in prod). - macOS-only force-exit timer in `init.ts` still fires after runWizard returns to drain the libuv handle for our fresh /dev/tty stream (same root cause as before, just different fd source). Comment updated to reflect the new owner.
Two visual fixes called out by the user:
1. **Clear the wizard chrome before printing the post-dispose
summary.** Previously the bordered wizard box stayed on screen
above the chalk summary, which was redundant and visually
noisy. `instance.clear()` now runs immediately before `unmount()`
so Ink rewinds the cursor and overwrites the rendered region;
the post-dispose `Setup complete` line + summary becomes the
only thing left on screen. The summary now writes to stdout
(was stderr) so it lands in the same stream as the cleared
Ink output — avoids potential interleave issues when the user
pipes stdout/stderr separately.
2. **Tighten sidebar spacing.** The three sidebar panels
(TipPanel, ProgressPanel, FilesPanel) had a `gap={1}` between
them, plus 1-row inner margins between each panel's title and
body. That was ~7 wasted rows on a typical run. Removed:
- The outer `gap={1}` between panels (now flush borders).
- `marginBottom={1}` after each panel title.
- `marginTop={1}` between TipPanel body and counter.
Tip-card body and counter are now stacked directly via the
normal flex flow; the rounded border + `paddingX={1}` already
provides enough visual separation. The `Did you know?` heading
moved into the bottom counter row (`Tip 3 of 12 · Did you
know?`) so the title row isn't wasted on a static label that
never changed.
3. **Better files-panel truncation indicator.** The "scroller"
the user asked for can't be a real interactive scroller —
Ink doesn't ship a scrollbox primitive, the file tree updates
frequently (new reads push the bottom), and adding `useInput`
to the panel would compete with the active prompt for key
events. Instead the tail-window UX is preserved with a
clearer indicator: `↑ N earlier (scrolled)` at the top when
rows are off-screen, and the panel header already shows
`Files analyzed (n/total)` so the user sees the full count.
Reserving 1 row for the header inside the maxRows budget
means the actual file-row count is honoured (previously the
header could squeeze the last visible file row).
Verification:
- bun run typecheck (clean)
- bun x ultracite check (1 pre-existing warning, no new ones)
- bun test --isolate test/lib/init/ (227 pass)
- SENTRY_CLIENT_ID=test bun run build (binary 108.79 MB,
no size regression)
- Smoke test confirmed: post-dispose summary stands alone,
no wizard box above it.
|
Codecov Results 📊✅ 6729 passed | Total: 6729 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
All tests are passing successfully. ❌ Patch coverage is 54.70%. Project has 14478 uncovered lines. Files with missing lines (11)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
- Coverage 76.62% 75.76% -0.86%
==========================================
Files 303 313 +10
Lines 57823 59734 +1911
Branches 0 0 —
==========================================
+ Hits 44307 45256 +949
- Misses 13516 14478 +962
- Partials 0 0 —Generated by Codecov Action |
| useInput((input, key) => { | ||
| if (key.ctrl && input === "c" && !snapshot.prompt) { | ||
| process.exit(130); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Bug: Pressing Ctrl+C during a spinner calls process.exit(130) directly, bypassing cleanup logic and leaving the terminal in a broken state (raw mode).
Severity: HIGH
Suggested Fix
Instead of calling process.exit(130) directly in the useInput handler, the component should signal the InkUI instance to initiate a graceful shutdown. This could be done by calling a dedicated cancel method, which would then throw a WizardCancelledError. This allows the await using block in wizard-runner.ts to correctly trigger the [Symbol.asyncDispose] method, ensuring all resources are cleaned up and the terminal state is restored properly before the process exits.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/init/ui/ink-app.tsx#L165-L169
Potential issue: When a user presses Ctrl+C while a spinner is active (and no prompt is
shown), a `useInput` handler in `ink-app.tsx` directly calls `process.exit(130)`. This
immediate process termination bypasses the `await using` disposal logic for the `InkUI`
instance. As a result, critical cleanup tasks in `[Symbol.asyncDispose]` are skipped.
This leaves the user's terminal in raw mode (disabling character echo) and leaks a
`/dev/tty` stream handle, which can prevent the process from exiting cleanly.
Did we get this right? 👍 / 👎 to inform future reviews.
CI's stricter biome version flagged the multi-line ternary in `FilesPanel`'s `visible` assignment. Auto-fixed by `bun x biome format --write`.
Addresses two HIGH-severity bug-prediction findings on PR #885 and restores the visual polish that the OpenTUI version had: 1. Ctrl+C during a spinner no longer calls process.exit(130) directly. The App's top-level useInput now routes through store.requestCancel(), which the InkUI bridge wires to a single requestCancel() entry point. That entry point either delegates to an active prompt's cancel callback (preserving the existing WizardCancelledError flow) or runs an idempotent tearDown() followed by process.exit(130) on the no-prompt path. 2. The SIGINT handler now funnels through the same requestCancel() so terminal restoration, /dev/tty release, post-dispose summary emission, and exit code are uniform across all three cancel entry points (App useInput, SIGINT, prompt). Switched process.on -> process.once so a stuck teardown can't hold the user hostage if Ctrl+C is pressed twice. 3. Two idempotency guards (torndown, cancelRequested) make tearDown() and the no-prompt branch of requestCancel() safe to call from multiple paths racing each other. Visual polish: - Divider now tracks main-column width (passed via prop) instead of hard-coding ".repeat(50)", so it doesn't truncate when the sidebar is visible nor look stubby on wide terminals. Capped at 56 to match banner row width. - Sidebar panel headers (TipPanel, ProgressPanel, FilesPanel) use bold-muted eyebrow + right-aligned counter pattern instead of a bold-accent title row. Reads as proper section chrome rather than competing with the actual content highlight (tip title in ACCENT) for the eye. - TipPanel counter moved to right-aligned bottom row so "Tip n of N" doesn't share a line with "Did you know?" eyebrow. Tests added for WizardStore.setRequestCancel covering initial state, registration, idempotency by reference, clearing on teardown, and round-trip invocation.
Remove the OutroScreen component and the outroDismissed promise that blocked teardown until the user pressed a key. The wizard now exits immediately on completion/failure — the post-dispose report in scrollback already shows the outcome summary.
The paddingX={1} on the outer content box was shifting the banner
and all content inward by 1 column on each side.
StatusScreen was receiving width-2 which made it narrower than its container, creating a visible left offset.
| if (key.end || key.escape) { | ||
| setPinnedToBottom(true); | ||
| setOffset(0); | ||
| } |
There was a problem hiding this comment.
Bug: In the FilesPanel, pressing the Escape key incorrectly scrolls the view to the bottom because it's grouped with the End key in an input handler.
Severity: MEDIUM
Suggested Fix
Remove the key.escape check from the conditional statement in the useInput handler. The condition should only check for key.end to trigger the scroll-to-bottom behavior, leaving the Escape key to its default (or no) action in this context.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/init/ui/ink-app.tsx#L825-L828
Potential issue: In the `FilesPanel` component, the `useInput` handler incorrectly
groups the `key.escape` and `key.end` keys. When the 'Files' tab is active and no prompt
is open, pressing the Escape key triggers a scroll-to-bottom action, because the
condition `if (key.end || key.escape)` is met. This is an unexpected user experience, as
the Escape key is typically used for cancellation or as a no-op, and the UI does not
indicate it will cause a scroll action in this context.
There was a problem hiding this comment.
Confirmed — this is a valid bug. Every other key.escape handler in this file (lines 1054, 1118, 1187) uses it for cancellation (prompt.resolve(null)). Grouping it with key.end here makes Escape scroll to the bottom, which is unexpected.
| if (key.end || key.escape) { | |
| setPinnedToBottom(true); | |
| setOffset(0); | |
| } | |
| if (key.end) { |
5cbfd9f to
2c0d25e
Compare
The paddingRight={1} on the left ActivityPane column caused
inconsistent left-side spacing when the prompt was active vs
dismissed — Ink's flex recalculation shifted content. Using
gap={1} on the parent row instead spaces the panes evenly
without affecting the content area width.
The outer Box with alignItems=center was causing the content to shift horizontally when prompt components mounted/unmounted — Ink recalculated the centering offset as content changed. Remove the wrapper entirely and render the inner box directly, which left-aligns all content flush with the terminal edge.
The 'renders full-screen layout' test started a spinner via store.startSpinner(), which caused ink-spinner's internal setInterval to keep the Node event loop alive. In CI (non-TTY), this prevented unmount()+waitUntilExit() from completing, hanging the test indefinitely. Replace with a log entry.
Use a pre-computed marginLeft based on (columns - width) / 2 to center the wizard horizontally. Unlike the previous alignItems=center approach, this margin is a static number that doesn't recalculate when content changes, preventing the left-side shift on prompt mount/unmount.
Lift the sidebar (LearnPanel/TipPanel + ProgressPanel) out of StatusScreen to the App level so it renders alongside whichever tab is active. The tabs now only control the left content area (Status: banner + logs + prompts, Files: file tree), while the right sidebar stays visible throughout.
- Remove escape key from FilesPanel scroll-to-bottom handler — escape should not trigger scrolling, only the End key should - Switch SIGINT handler from process.once to process.on so a second Ctrl+C during a prompt-active window still routes through the custom handler instead of falling through to Node's default (which kills without cleanup) - Move checkReadiness() before preamble() so prerequisites are verified before asking the user for consent to proceed
In CI, Ink's waitUntilExit() can hang after unmount() when the internal reconciler or ink-spinner intervals keep the event loop alive. Race the promise against a 500ms timeout so the test proceeds instead of blocking the entire test suite.
The previous timeout race still kept the event loop alive because the setTimeout timer was ref'd. Use .unref() so the timer doesn't prevent process exit when waitUntilExit() hangs in CI non-TTY.
| * 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.on("SIGINT", handler); |
There was a problem hiding this comment.
Bug: The SIGINT handler uses process.on instead of the documented process.once, which prevents the intended force-exit safety mechanism on a second Ctrl+C signal during a stuck teardown.
Severity: LOW
Suggested Fix
To implement the documented safety feature, change process.on("SIGINT", handler) to process.once("SIGINT", handler). This ensures that if the teardown process hangs, a second Ctrl+C signal will trigger Node.js's default behavior and terminate the process immediately. Alternatively, if ignoring subsequent signals is the desired behavior, update the comment to reflect the actual implementation.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/init/ui/ink-ui.ts#L854-L864
Potential issue: A safety mechanism to handle a stuck teardown process is not
implemented correctly. A comment in `installCancelHandler` states that
`process.once('SIGINT', ...)` is used to allow a second Ctrl+C signal to force-exit the
application if the teardown hangs. However, the code actually uses `process.on('SIGINT',
...)`. In conjunction with a guard clause in `requestCancel`, this causes any subsequent
Ctrl+C signals to be silently ignored. While the current synchronous `tearDown`
implementation makes a hang unlikely, this discrepancy means the intended protection
against a stuck process is missing.
There was a problem hiding this comment.
Good catch. The comment says process.once but the code used process.on. With process.on, the handler stays registered and the cancelRequested guard in requestCancel() silently swallows subsequent SIGINTs — so a second Ctrl+C during a stuck teardown would be a no-op instead of falling through to Node's default handler (force exit).
Fixed in 90c76b4 — changed process.on("SIGINT", handler) to process.once("SIGINT", handler).
… second Ctrl+C The comment documented process.once but the code used process.on, silently swallowing subsequent SIGINT signals via the cancelRequested guard instead of letting them fall through to Node's default handler.
…gnals process.once was consumed after the first Ctrl+C (e.g. when a prompt was active and delegated to promptCancel). A second Ctrl+C would then fall through to Node's default handler, killing the process without cleanup. process.on ensures the custom handler stays registered; requestCancel's cancelRequested flag provides idempotency.
waitUntilExit() creates an internal promise that keeps Ink's event loop alive indefinitely in CI non-TTY environments, even after unmount(). Previous attempts (timeout race, unref) didn't help because the pending promise itself prevents bun test from exiting the worker. Simply skip it — frames are already captured after the 80ms settle + unmount.
Call waitUntilExit() via instance reference with a 500ms unref'd timeout race. Also unref the settle timer. This ensures bun test can exit the worker even if Ink's internal promise never resolves in non-TTY CI environments.
Move the narrow-width test before the 120-col test. In CI, the first Ink render() call in a bun test worker may initialize internal infrastructure differently, causing the wide layout test to hang when it runs first.
- Mock checkReadiness in wizard-runner tests to prevent unmocked
fetch calls to the Mastra health endpoint
- Update 'shows a multiline tree' test to expect count-based
messages ('Reading 2 files...') instead of tree-style output
with branch characters, matching the simplified spinner format
- Clean up snapshot test for CI reliability
| * 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.on("SIGINT", handler); | ||
| } |
There was a problem hiding this comment.
Bug: The SIGINT handler uses process.on instead of the documented process.once, preventing a second Ctrl+C from force-exiting a hung teardown process.
Severity: MEDIUM
Suggested Fix
Replace process.on('SIGINT', handler) with process.once('SIGINT', handler). This aligns the code with the documentation and ensures that after the first cancellation signal, a subsequent SIGINT will trigger Node's default behavior, which is to terminate the process immediately.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/init/ui/ink-ui.ts#L854-L865
Potential issue: The docstring for `installCancelHandler` states that `process.once` is
used for the `SIGINT` handler to allow a second Ctrl+C to force-exit the process.
However, the implementation at line 864 uses `process.on`. If the `tearDown()` process
hangs, the listener remains active. A subsequent Ctrl+C will trigger the handler, but an
early return due to the `cancelRequested` flag at line 729 prevents any action, trapping
the user in a hung state without the ability to force-exit.
There was a problem hiding this comment.
Valid catch. The earlier fix (90c76b4) switched to process.once but that was reverted in 361da11 because once gets consumed by a prompt-delegation Ctrl+C (where requestCancel returns early without setting cancelRequested), leaving subsequent signals unhandled.
The real fix is to keep process.on for persistence but change the cancelRequested guard from a silent no-op to a force-exit. Fixed in 28e9e93 — a second Ctrl+C while teardown is in progress now calls process.exit(130) instead of returning. Updated docstrings in both requestCancel and installCancelHandler to reflect the actual behavior.
The SIGINT handler used process.on (to survive prompt-delegation) but the cancelRequested guard silently swallowed subsequent signals, trapping the user if teardown hung. Now requestCancel() force-exits when cancelRequested is already set. Updated docstrings in both requestCancel and installCancelHandler to match the actual behavior.
Conflict resolutions: - wizard-runner.ts: integrate agent detection for banner suppression with ui.banner() delegation pattern; add exit code mapping from main - AGENTS.md: take main's version (auto-generated) - wizard-runner.test.ts: update banner test to check ui mock calls instead of stderr spy; mock checkReadiness in all tests
When InkUI fails to import in an interactive session, the factory falls back to LoggingUI. confirmExperimental then calls ui.select() which throws LoggingUIPromptError — an error the catch block did not handle, causing an unhandled crash. Catch it and surface a clear WizardError directing the user to --yes.
| this.startTipRotation(); | ||
| return; | ||
| } | ||
| const next = learnState.blockIndex + 1; | ||
| if (next >= LEARN_SEQUENCE.length) { | ||
| store.setLearnComplete(); | ||
| this.stopLearnSequence(); | ||
| this.startTipRotation(); | ||
| } else { | ||
| store.advanceLearnBlock(); |
There was a problem hiding this comment.
Bug: A race condition between tearDown() and a queued learnTimer callback can lead to startTipRotation() being called after teardown, leaving an uncleared tipTimer.
Severity: MEDIUM
Suggested Fix
To prevent the race condition, add a check for the this.torndown flag inside the learnTimer callback before calling startTipRotation(). This ensures that if tearDown() has been initiated, no new timers will be created.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/init/ui/ink-ui.ts#L822-L831
Potential issue: A race condition exists in the component's teardown logic. If
`tearDown()` is called at a specific moment after a `learnTimer` callback has been
queued but before it executes, the `clearInterval` call in `tearDown` will not prevent
that queued callback from running. When this callback runs, it can call
`startTipRotation()`, which sets a new `tipTimer`. Because the `tearDown` logic has
already completed, this new timer will never be cleared, resulting in a memory leak and
potentially unexpected behavior from the orphaned interval.
There was a problem hiding this comment.
Good catch — this is a real race. A queued learnTimer callback can fire after tearDown() clears the interval, calling startTipRotation() and leaving an orphaned tipTimer.
Fixed in 54b64de by adding a this.torndown guard at the top of the callback.
Prevent a race where a queued learnTimer callback fires after tearDown() has completed, creating an orphaned tipTimer interval.
The learnTimer callback could fire after tearDown() and call startTipRotation(), creating an orphaned tipTimer that would never be cleared. Add torndown checks before creating new timers and actively stop the learn sequence if teardown is detected mid-callback.
Summary
Replaces the OpenTUI implementation (Zig-compiled native binary, ~10.7 MB of binary cost, Bun-only) with Ink — pure JS + React, no native bindings. Same
WizardUIsurface, sameWizardStore, same sidebar layout, same step checklist — different render primitives.Why
.so/.dylib/.dllfiles viabun:ffi, inflating the binary by ~10.7 MB. Ink is pure JS, so the binary drops from ~118 MB → ~109 MB (-9.4 MB).ink+ink-spinnercover most of what we hand-rolled in OpenTUI. Used by Wrangler, Gatsby, GitHub Copilot CLI, and others.What's in this PR
Four commits, in order:
d1ca5f7afeat(init): replace OpenTUI with Ink — initial port. Includes thewith { type: "file" }workaround for Bun.compile bundling React's CJS dev wrappers (otherwise hits a__promiseAllSyntaxError at startup).59900dbdfix(init): make Ink select prompt actually respond to arrow keys — replacedink-select-inputwith a hand-rolleduseInputimplementation. The third-party component races with our store-driven re-renders.b4a591e2fix(init): make Ink useInput actually deliver keystrokes in Bun — pass a fresh/dev/ttyReadStreamto Ink'sstdinoption to work around oven-sh/bun#6862 + vadimdemedes/ink#636 (Bun'sprocess.stdindoesn't deliverreadableevents).4a3e8354fix(init): clear screen on dispose + tighten sidebar layout —instance.clear()before unmount so the wizard chrome doesn't linger above the post-dispose summary; removed wasted rows between sidebar panels.Things that stayed the same
WizardUIinterface (banner / intro / log / spinner / select / multiselect / confirm / summary / cancel / outro / setStep / recordFilesReading / markFilesAnalyzed)WizardStore+useSyncExternalStoresubscription pattern (renamedopentui-store.ts→wizard-store.ts)file-tree.ts,sentry-tips.ts,types.ts(unchanged in shape)Things that changed
↑ N earlier (scrolled)hint when truncated. The tail-fUX (newly-read files always visible) comes for free since the panel re-renders to the bottom.useInput(no third-party multiselect component).keyHandlerwas global; Ink'suseInputis per-component. Cancellation now hooks into:useInput(handleskey.escapeandkey.ctrl && input === "c"in raw mode where Node doesn't emit SIGINT).useInputthat intercepts Ctrl+C during spinners (no prompt mounted).process.on("SIGINT", …)fallback insideInkUIfor the brief window where raw mode flickers off.Bun-binary-only (same as OpenTUI was)
Ink's reconciler and the
yoga-layoutdependency use top-level await, which esbuild can't emit in our CJS npm bundle. So Ink is bundled into the Bun binary via thewith { type: "file" }trick (same as OpenTUI used) but excluded fromdist/index.cjsentirely. Node users continue to getLoggingUI— unchanged from before. This preserves AGENTS.md's "no runtime dependencies" rule.bun run check:depspasses.Bun.compile workarounds (unavoidable)
with { type: "file" }keepsink-app.tsxout of esbuild's and Bun.compile's static bundle graph. Without this, Bun.compile mangles Ink's and React's CJS dev wrappers (it injects__promiseAllruntime helpers in positions the IIFEs can't parse, producingSyntaxError: Unexpected identifier '__promiseAll'at startup insideparse-keypress.jsorreact-jsx-runtime.development.js).?bridge=1query string on the dynamic import bypasses Bun's module-cache collision between the file-resource import andawait import(path)of the same absolute path.define: { 'process.env.NODE_ENV': '"production"' }onBun.buildforces React to use its production builds.react-devtools-coreinstalled as a devDep so Bun.compile can resolve Ink's static reference (gated behindprocess.env.DEV === "true"at runtime → dead code in production).Files changed
Added
src/lib/init/ui/ink-app.tsx— Ink React treesrc/lib/init/ui/ink-ui.ts—InkUIbridge classRenamed
src/lib/init/ui/opentui-store.ts→wizard-store.ts(no logic changes)test/lib/init/ui/opentui-store.test.ts→wizard-store.test.tsDeleted
src/lib/init/ui/opentui-app.tsxsrc/lib/init/ui/opentui-ui.tsDep changes
@opentui/core,@opentui/reactink,ink-spinner,react-devtools-core(all devDependencies)Verification
bun run typecheck(clean)bun x ultracite check(1 pre-existing warning, no new ones)bun test --isolate test/lib/init/(227 pass)bun run check:deps(no runtime dependencies)SENTRY_CLIENT_ID=test bun run build(binary 108.79 MB, -9.4 MB vs OpenTUI's 118.23 MB)SENTRY_CLIENT_ID=test bun run bundle(npm 3.29 MB, unchanged)./dist-bin/sentry-linux-x64 init --help(renders cleanly)node ./dist/bin.cjs init --help(Node path renders cleanly)