feat(cli): add experimental interactive Ink TUI#979
Conversation
Add `react-doctor experimental-tui`, an Ink-based interactive scan report that streams diagnostics live, then renders a scrollable, score-sorted view (no top-3 truncation) with the doctor-face score header, score projection, per-category breakdown, and an inline code frame. Supports monorepo project selection and a multi-project summary with per-project drill-in. Ink/React load lazily so the default static, JSON, and score-only paths are unaffected.
|
React Doctor found 7 new issues in 3 files · 7 warnings · score 86 / 100 (Great) · 0 fixed · vs 7 warnings
Reviewed by React Doctor for commit |
commit: |
resolveScanTarget now feeds the experimental TUI the same on-disk config the static CLI uses: the rootDir redirect, the persistent `projects` list, and the `share` gate (so `share: false` suppresses the Share URL in the exit footer).
Extract a shared countUniqueScannedFiles util (deduping by absolute path) and use it for the TUI's multi-project exit footer, so nested workspace packages no longer double-count shared files — parity with the static monorepo summary, which now reuses the same helper.
…t failures The monorepo multiselect submitted on a literal carriage return, but Ink reports Enter via key.return (matching useScrollViewport), so confirming the selection could silently no-op. Switch to key.return and add a regression test. Also surface oxlint failures in the TUI: the post-scan lint-failure hint is suppressed when uiStore is active, so the exit footer now prints the reason from the result's skippedCheckReasons — a TUI report can no longer look clean while lint silently failed.
resolveIsOffline gated the Share footer on coding-agent environments too, but the static CLI's shouldShowShareLink only checks CI. Align to isCiEnvironment so the same run shows Share consistently across the default CLI and the TUI.
| projectFlag: options.project, | ||
| skipPrompts: options.yes ?? false, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
TUI always exits zero
Medium Severity
experimental-tui awaits runScanApp, which returns error and warning counts, but the command handler never sets process.exitCode or applies the blocking gate. The default inspect path uses finalizeScans and shouldBlockCi on ciFailure diagnostics, so CI can fail on blocking issues; the TUI path always exits 0 after a successful run.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit d46a322. Configure here.
| // "N unread" counter tracks how far through the queue you are. | ||
| useEffect(() => { | ||
| if (!selectedRuleKey) return; | ||
| setReadRuleKeys((previous) => |
There was a problem hiding this comment.
React Doctor · react-doctor/no-derived-state (warning)
Storing "readRuleKeys" in state when you can derive it from other values costs an extra render.
Fix → Work out the value while rendering (or with useMemo if it's expensive) instead of copying it into useState through a useEffect. See https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state
| > | ||
| {rightContent} | ||
| <Box flexGrow={1} /> | ||
| {renderBottomMenu(detailColumnWidth - 1)} |
There was a problem hiding this comment.
React Doctor · react-doctor/no-render-in-render (warning)
Your users lose state because "renderBottomMenu()" builds UI from an inline call that React remounts, so pull it into its own component instead.
Fix → Make it a named component so React preserves its identity and does not remount its state.
| <Box marginTop={1}>{listColumn}</Box> | ||
| <Text dimColor>{"─".repeat(width)}</Text> | ||
| {rightContent} | ||
| {renderBottomMenu(width)} |
There was a problem hiding this comment.
React Doctor · react-doctor/no-render-in-render (warning)
Your users lose state because "renderBottomMenu()" builds UI from an inline call that React remounts, so pull it into its own component instead.
Fix → Make it a named component so React preserves its identity and does not remount its state.
| <Text bold={isSelected} wrap="truncate-end"> | ||
| {[...name].map((char, index) => | ||
| matched.has(index) ? ( | ||
| <Text key={index} color="yellow"> |
There was a problem hiding this comment.
React Doctor · react-doctor/no-array-index-as-key (warning)
Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "index".
Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.
| * scans the checked set (or the highlighted row when none are), and `Esc` backs | ||
| * out a level (filter → selection → cancel). The footer states what `Enter` does. | ||
| */ | ||
| export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSelectProps) => { |
There was a problem hiding this comment.
React Doctor · react-doctor/prefer-useReducer (warning)
5 useState calls in "ProjectSelect" can each trigger a separate render.
Fix → Group related state in useReducer so one logical update does not fan out into separate renders.
| const scanSelection = (): void => { | ||
| if (checked.size > 0) { | ||
| return submit( | ||
| packages.filter((pkg) => checked.has(pkg.directory)).map((pkg) => pkg.directory), |
There was a problem hiding this comment.
React Doctor · react-doctor/js-combine-iterations (warning)
This loops over your list twice because .filter().map() makes two passes, so do it in one pass with .reduce() or a for...of loop
Fix → Combine .map().filter() style chains into one pass with .reduce() or a for...of loop, so you only loop over the list once
| export const Report = ({ | ||
| report, | ||
| onExit, | ||
| launchableAgents = [], |
There was a problem hiding this comment.
React Doctor · react-doctor/rerender-memo-with-default-value (warning)
This keeps redrawing children that compare props because default prop value [] makes a brand new array every render, so move it to a constant at the top of the file
Fix → Move it to the top of the file: const EMPTY_ITEMS: Item[] = [], then use that as the default value
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit db4a840. Configure here.
| launchableAgents={launchableAgents} | ||
| onHandoff={onHandoff} | ||
| projectCount={summary.projects.length} | ||
| /> |
There was a problem hiding this comment.
Monorepo handoff wrong project name
Medium Severity
In the multi-project TUI summary, copy/handoff prompts use the monorepo root folder name (summary.projectName) for every selected issue, even when the diagnostic lives under a nested workspace package. The list and code frames already use folder-qualified paths, so agent prompts can name the wrong project context.
Reviewed by Cursor Bugbot for commit db4a840. Configure here.


Why
The default report shows only the top 3 issues, which buries most findings, and the interactive bits are hand-rolled ANSI. This adds an opt-in, scrollable interactive report (behind the hidden
experimental-tuicommand) so users can review all diagnostics, sorted by score priority, with the issue's code shown inline — without changing the default static/JSON/score-only output.Before:
react-doctorprints the score header + top-3 issues statically; no scroll, no full list, no inline code.After:
react-doctor experimental-tuistreams diagnostics live during the scan, then opens a scrollable, score-sorted report:↑↓/j k, an inline syntax-highlighted code frame for the selected issue, and a status bar.Ctrl+Cforce-exits from the interactive views; stdin stays cooked during the scan so a realSIGINTstill applies.What changed
packages/react-doctor/src/cli/ink/**: an externalscan-storebridged to React viauseSyncExternalStore, therun-scan-apporchestrator (discovery → selection → single/multi scan → score projection → exit footer), components (score-header, category-breakdown, diagnostic list/item/detail, status-bar, report, summary, project-select, scanning), hooks (scroll viewport, stdout dimensions, scan store, exit-on-ctrl-c), and lib helpers (diagnostic-rows, category-tallies, score-color, severity-variants).src/inspect.ts: optionaluiStorethat swaps in store-backed Reporter/Progress layers; default path untouched.src/cli/index.ts: registers the hiddenexperimental-tuicommand (--no-dead-code,--no-score,-p/--project,-y/--yes); Ink/React are imported lazily so the default path never pays their startup cost.discoverWorkspacePackagesfromselect-projects.ts; extracted sharedscoreBandLabel(replacing the private copy inrender-multi-project-summary.ts).ink/react/ink-spinnerin the bundle.tests/ink/**(scan flow, score projection, no-score header, monorepo summary, Ctrl+C handler).Test plan
pnpm --filter react-doctor typecheckpnpm --filter react-doctor lint(no new warnings)pnpm --filter react-doctor format:checknr test— local run was blocked by an unrelated concurrent workload saturating the machine (network/git-heavy regression tests timed out); relying on CI for the authoritative full-suite resultCtrl+Cexit🤖 Reviewed with two Thermos passes (bug-audit + code-quality) before opening.
Note
Medium Risk
Large new interactive CLI path and runtime deps (Ink/React), but it is experimental, lazy-loaded, and the default scan output path is unchanged aside from optional
uiStorewiring ininspect().Overview
Adds
react-doctor experimental-tui, an opt-in Ink/React terminal UI that lazy-loads so the default static, JSON, and score-only flows stay unchanged.During a scan, diagnostics and progress stream into a
ScanStorewired through optionaluiStoreoninspect()(store-backed Reporter / Progress layers, console rendering suppressed). After the scan, users get a full score-priority rule list (no top-3 cap), doctor-face score header with projection ghost bar, category-grouped navigation, inline code frames, triage actions (copy prompt / launch detected CLI agents), and split vs stacked layout by terminal size.Monorepos: Ink project multiselect (fuzzy search, shared
discoverWorkspacePackages) and a flat combined summary (worst-project aggregate score, folder-qualified paths). Exit still prints the usual static footer (files scanned, share/docs hints, lint skip reasons).Core:
CATEGORY_IMPACT/getCategoryImpactfor short “why this category matters” copy in the TUI detail pane.Small shared extractions (
countUniqueScannedFiles,detectLaunchableAgents,scoreBandLabel) dedupe static CLI behavior; Ink/React are build externals with newtests/ink/**coverage.Reviewed by Cursor Bugbot for commit db4a840. Bugbot is set up for automated code reviews on this repo. Configure here.