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 9 commits intofeat/init-wizard-ui-abstractionfrom
Conversation
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 📊✅ 6338 passed | Total: 6338 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
All tests are passing successfully. ❌ Patch coverage is 47.46%. Project has 13460 uncovered lines. Files with missing lines (5)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
- Coverage 76.03% 75.73% -0.3%
==========================================
Files 294 302 +8
Lines 54404 55463 +1059
Branches 0 0 —
==========================================
+ Hits 41362 42003 +641
- Misses 13042 13460 +418
- 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.
Sweep the surrounding init/ files (everything except ink-ui.ts and ink-app.tsx, where OpenTUI references intentionally document history) so doc comments accurately describe the current Ink-based implementation. No behavior changes. Touched files: - clack-utils.ts: sidebar comment refs - formatters.ts: header explaining why summary is structured data - git.ts, interactive.ts: paths through WizardUI - types.ts (init): forceLegacyUi rationale - ui/file-tree.ts: tree builder's two consumers - ui/logging-ui.ts: tree-row format docstring - ui/sentry-tips.ts: where tips render - ui/types.ts: WizardSummary, banner, summary, recordFilesReading, setStep doc rewrites - wizard-runner.ts: header + four inline references
Adds `test/lib/init/ui/ink-app.snapshot.test.tsx` — four smoke tests
that mount the React tree directly via Ink's `render()` API with a
captured `Writable` stream, then assert against the rendered frames:
1. **Banner + sidebar at 120 cols.** Verifies the box-drawing
banner renders, the sidebar's three panels (`Did you know?`,
`Progress`, file tree) appear, and live log/spinner content
reaches the frame.
2. **Sidebar hides at 80 cols.** Confirms `SIDEBAR_BREAKPOINT`
gating works — banner + log render but the panel headers are
absent.
3. **Divider tracks main-column width.** Asserts the new
`Divider` component grows up to 56 cols on an 80-col terminal
(vs. the old hard-coded 50). Picks the second-longest unique
`─` run length to skip the outer wizard chrome border.
4. **Ctrl+C path uses requestCancel.** Smoke-checks that
`WizardStore.setRequestCancel` round-trips correctly so the
App's top-level `useInput` handler can route Ctrl+C through
the bridge's idempotent teardown rather than calling
`process.exit(130)` directly.
Why mount Ink directly instead of spawning the binary?
Bun-compiled standalone binaries report `process.stdin.isTTY:
false` even when launched through `script(1)` or a hand-allocated
`/dev/ptmx` pair (verified via `dlopen("libc.so.6")` +
`posix_openpt` from a driver script). The `factory.ts`
TTY check therefore routes to `LoggingUI` and prompts throw
`LoggingUIPromptError` before any visual work happens. Mounting
the React tree directly via Ink's `render()` API sidesteps that
detection and exercises the actual visual contract.
The `CaptureStream` writable concatenates every chunk Ink emits
(it splits a render across cursor moves + sync flag + content +
sync unflag — the last chunk alone is usually a control
sequence). The `makeStdin()` shim implements just enough of the
`ReadStream` surface for Ink's raw-mode toggling.
All 6337 tests pass (+4 new); biome lint clean (1 pre-existing
warning unchanged).
Restores the scrollable Files Analyzed sidebar that the OpenTUI
implementation had via `<scrollbox stickyStart="bottom">`. Ink
doesn't ship a scrollbox primitive, so this is a hand-rolled
component with two improvements over the auto-only OpenTUI version:
1. **Visual scrollbar.** A 1-column track of `│` glyphs on the
right edge of the panel, with a `█` thumb whose position +
size reflects the visible window's place in the full
read-tree. Shown only when content exceeds the viewport;
hidden when everything fits.
2. **Keyboard scroll-back.** While no prompt is mounted (gated
on `!hasActivePrompt` so it never fights a select/multiselect's
own `useInput`):
- ↑ / ↓ — scroll one row
- PgUp / PgDn — scroll one viewport
- Home — jump to oldest entry
- End / Esc — re-pin to latest (auto-follow)
Auto-follow ("pinned to bottom") is the default — newly-read
files always come into view, like `tail -f`. The user can scroll
back; while unpinned, new file arrivals don't shift the visible
window (the panel bumps `offset` by the new-row count to keep
the view stable). Pressing PgDn / ↓ until offset reaches 0
re-pins automatically, so the user doesn't need to remember to
press End.
Architecture:
- Scroll state (`pinnedToBottom`, `offset`) is React `useState`
inside FilesPanel — UI concern, not wizard-store domain.
- A `useEffect` watching `totalRows` handles three cases:
arrival-while-unpinned (bump offset), shrink (clamp offset to
new maxOffset), and pinned-to-bottom (no-op).
- Header shows a `↑ ` prefix on the counter when scrolled up,
signaling the panel won't auto-follow.
- App threads `hasActivePrompt = snapshot.prompt !== null`
through Sidebar to FilesPanel — single boolean, no
refactoring of existing layout.
Tests:
- New snapshot test "FilesPanel renders scrollbar when content
exceeds viewport" compares `█` counts between a few-files
baseline and a many-files render. Banner ASCII art uses U+2588
too, so exact-pattern matching can't distinguish thumb from
banner — count-delta works around that.
- Header pinned-state regex confirms the `↑ ` prefix is absent
when (default) the panel auto-follows.
Verification: 6338/6338 tests pass (+1 new), lint clean (1
pre-existing warning unchanged), binary at 108.80 MB
(unchanged).
| private installCancelHandler(): void { | ||
| const handler = () => { | ||
| this.requestCancel(); | ||
| }; | ||
| this.cancelHandler = handler; | ||
| process.once("SIGINT", handler); | ||
| } |
There was a problem hiding this comment.
Bug: The SIGINT handler uses process.once, so a second Ctrl+C can terminate the process without cleanup, leaving the terminal in raw mode.
Severity: MEDIUM
Suggested Fix
Replace process.once('SIGINT', handler) with process.on('SIGINT', handler) to ensure the handler persists across multiple signals. To prevent the handler from remaining active after the wizard is complete, explicitly remove it by calling process.off('SIGINT', handler) as part of the tearDown logic.
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#L711-L717
Potential issue: The `SIGINT` handler is registered using `process.once`, which means it
is removed after its first invocation. If a user sends a `SIGINT` signal (e.g., via
Ctrl+C) while a prompt is active, the handler correctly delegates to `promptCancel()`
and returns. However, this action consumes the handler. If the user sends a second
`SIGINT` signal later in the process (e.g., during a spinner), no custom handler exists.
This falls through to Node's default behavior, which terminates the process immediately,
bypassing cleanup and leaving the terminal in raw mode.
Also affects:
src/lib/init/ui/ink-ui.ts:604~624
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)