Skip to content

feat(init): replace OpenTUI with Ink for the wizard UI#885

Open
MathurAditya724 wants to merge 68 commits intomainfrom
feat/init-wizard-ink
Open

feat(init): replace OpenTUI with Ink for the wizard UI#885
MathurAditya724 wants to merge 68 commits intomainfrom
feat/init-wizard-ink

Conversation

@MathurAditya724
Copy link
Copy Markdown
Member

Stacked on top of #862 (OpenTUI / WizardUI scaffold). Once #862 merges, GitHub auto-retargets this PR to main.

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 WizardUI surface, same WizardStore, same sidebar layout, same step checklist — different render primitives.

Why

  • No native binary cost. OpenTUI ships per-platform .so/.dylib/.dll files via bun:ffi, inflating the binary by ~10.7 MB. Ink is pure JS, so the binary drops from ~118 MB → ~109 MB (-9.4 MB).
  • No alternate-screen flicker. OpenTUI took over the alternate-screen buffer; on dispose every trace of the run was wiped. We had to replay a stripped-down transcript to stderr so users had any scrollback. Ink renders inline.
  • Mature ecosystem. ink + ink-spinner cover 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:

  1. d1ca5f7a feat(init): replace OpenTUI with Ink — initial port. Includes the with { type: "file" } workaround for Bun.compile bundling React's CJS dev wrappers (otherwise hits a __promiseAll SyntaxError at startup).
  2. 59900dbd fix(init): make Ink select prompt actually respond to arrow keys — replaced ink-select-input with a hand-rolled useInput implementation. The third-party component races with our store-driven re-renders.
  3. b4a591e2 fix(init): make Ink useInput actually deliver keystrokes in Bun — pass a fresh /dev/tty ReadStream to Ink's stdin option to work around oven-sh/bun#6862 + vadimdemedes/ink#636 (Bun's process.stdin doesn't deliver readable events).
  4. 4a3e8354 fix(init): clear screen on dispose + tighten sidebar layoutinstance.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

  • WizardUI interface (banner / intro / log / spinner / select / multiselect / confirm / summary / cancel / outro / setStep / recordFilesReading / markFilesAnalyzed)
  • The external WizardStore + useSyncExternalStore subscription pattern (renamed opentui-store.tswizard-store.ts)
  • file-tree.ts, sentry-tips.ts, types.ts (unchanged in shape)
  • Sidebar layout: tip card 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 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 (scrolled) 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 (no third-party multiselect component).
  • Cancellation. OpenTUI's keyHandler was global; Ink's useInput is per-component. Cancellation now hooks into:
    • Each prompt's own useInput (handles key.escape and key.ctrl && input === "c" in raw mode where Node doesn't emit SIGINT).
    • A top-level App-component useInput that intercepts Ctrl+C during spinners (no prompt mounted).
    • A process.on("SIGINT", …) fallback inside InkUI for the brief window where raw mode flickers off.

Bun-binary-only (same as OpenTUI was)

Ink's reconciler and the yoga-layout dependency 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 { type: "file" } 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 (unavoidable)

  • with { type: "file" } keeps ink-app.tsx out 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 __promiseAll runtime helpers in positions the IIFEs can't parse, producing SyntaxError: Unexpected identifier '__promiseAll' at startup inside 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 await import(path) of the same absolute path.
  • define: { 'process.env.NODE_ENV': '"production"' } on Bun.build forces React to use its production builds.
  • react-devtools-core installed as a devDep so Bun.compile can resolve Ink's static reference (gated behind process.env.DEV === "true" at runtime → dead code in production).

Files changed

Added

  • src/lib/init/ui/ink-app.tsx — Ink React tree
  • src/lib/init/ui/ink-ui.tsInkUI bridge class

Renamed

  • src/lib/init/ui/opentui-store.tswizard-store.ts (no logic changes)
  • test/lib/init/ui/opentui-store.test.tswizard-store.test.ts

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, 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)

MathurAditya724 and others added 24 commits April 29, 2026 03:19
Introduces a thin `WizardUI` interface as the single I/O chokepoint for
the init wizard, with two implementations:

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

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

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

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

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

Migrated modules:

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

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

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

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

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

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

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

Wiring:

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

Build / bundle:

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

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

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

Selection rules (post-flip):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Visual changes:

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

Implementation changes:

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

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

Three changes triggered the rewrite:

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

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

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

## Architecture

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

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

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

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

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

## New `WizardUI.summary()` method

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

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

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

## Imports / build

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

## Tests

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

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

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

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

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

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

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

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

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

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

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

       ✔  Sentry SDK installed successfully!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two related fixes:

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

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

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

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

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

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

## Tree view for changed files

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

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

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

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

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

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

## 'Files analyzed' sidebar panel

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Implementation details:

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

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

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

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

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

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

Tests:

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

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

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

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

github-actions Bot commented Apr 29, 2026

PR Preview Action v1.8.1

QR code for preview link

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

Built to branch gh-pages at 2026-05-05 00:27 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Codecov Results 📊

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

📊 Comparison with Base Branch

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

All tests are passing successfully.

❌ Patch coverage is 54.70%. Project has 14478 uncovered lines.
❌ Project coverage is 75.76%. Comparing base (base) to head (head).

Files with missing lines (11)
File Patch % Lines
src/lib/init/ui/ink-app.tsx 35.85% ⚠️ 612 Missing
src/lib/init/wizard-runner.ts 46.12% ⚠️ 111 Missing
src/lib/init/ui/wizard-store.ts 64.32% ⚠️ 86 Missing
src/lib/init/ui/logging-ui.ts 60.42% ⚠️ 57 Missing
src/lib/init/readiness.ts 7.55% ⚠️ 49 Missing
src/lib/init/ui/file-tree.ts 82.93% ⚠️ 21 Missing
src/lib/init/ui/factory.ts 52.63% ⚠️ 18 Missing
src/commands/init.ts 30.43% ⚠️ 16 Missing
test/lib/init/ui/mock-ui.ts 88.75% ⚠️ 9 Missing
src/lib/init/preflight.ts 83.67% ⚠️ 8 Missing
src/lib/init/git.ts 91.67% ⚠️ 1 Missing
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

Comment on lines +165 to +169
useInput((input, key) => {
if (key.ctrl && input === "c" && !snapshot.prompt) {
process.exit(130);
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/init/ui/ink-ui.ts Outdated
CI's stricter biome version flagged the multi-line ternary in
`FilesPanel`'s `visible` assignment. Auto-fixed by
`bun x biome format --write`.
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Comment thread src/lib/init/ui/ink-ui.ts Outdated
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.
Comment thread src/lib/init/ui/ink-app.tsx Outdated
Comment on lines +825 to +828
if (key.end || key.escape) {
setPinnedToBottom(true);
setOffset(0);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (key.end || key.escape) {
setPinnedToBottom(true);
setOffset(0);
}
if (key.end) {

Comment thread src/lib/init/wizard-runner.ts Outdated
@MathurAditya724 MathurAditya724 force-pushed the feat/init-wizard-ink branch from 5cbfd9f to 2c0d25e Compare May 4, 2026 22:29
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.
Comment thread src/lib/init/ui/ink-ui.ts
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Comment on lines +854 to +864
* 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Comment on lines +855 to +865
* 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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MathurAditya724 MathurAditya724 changed the base branch from feat/init-wizard-ui-abstraction to main May 5, 2026 00:12
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.
Comment thread src/lib/init/preflight.ts
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.
Comment thread src/lib/init/ui/ink-ui.ts Outdated
Comment on lines +822 to +831
this.startTipRotation();
return;
}
const next = learnState.blockIndex + 1;
if (next >= LEARN_SEQUENCE.length) {
store.setLearnComplete();
this.stopLearnSequence();
this.startTipRotation();
} else {
store.advanceLearnBlock();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants