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 6 new issues in 4 files · 6 warnings · score 86 / 100 (Great) · 0 fixed · vs 6 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d46a322. Configure here.
| 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.
| * `DiagnosticList`'s key handler so a focused index always maps to the same | ||
| * action: Copy, then each launchable agent, then the read toggle. | ||
| */ | ||
| export const actionCount = (agentCount: number): number => agentCount + 2; |
There was a problem hiding this comment.
React Doctor · react-doctor/only-export-components (warning)
This file exports non-components, so Fast Refresh can't safely preserve component state.
Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.
| // toggling it back to unread sticks until the selection moves elsewhere. | ||
| 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
| <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


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 surface and scan pipeline hooks (
uiStore), but gated behind an experimental command with lazy deps and no change to default output paths.Overview
Introduces
react-doctor experimental-tui, an opt-in Ink/React terminal UI that lazy-loads so static, JSON, and score-only flows stay unchanged.During a scan, diagnostics and progress stream through a
ScanStorewired intoinspect()via optionaluiStoreand store-backed Reporter/Progress layers (console rendering is suppressed on that path). After the scan, users get a score-sorted, non-truncated rule list with doctor-face header, projection bar, category grouping, inline code frames, and triage actions (copy prompt, launch agents, read/unread). Monorepos get an Ink project multiselect and a combined summary (worst-project aggregate score, paths qualified to the repo root).@react-doctor/coregainsCATEGORY_IMPACT/getCategoryImpactfor category-level “why it matters” copy in the TUI detail pane. Small shared extractions:discoverWorkspacePackages,countUniqueScannedFiles,detectLaunchableAgents,scoreBandLabel. Build adds JSX and externalizes Ink/React;tests/ink/**covers the main flows.Reviewed by Cursor Bugbot for commit b7022f9. Bugbot is set up for automated code reviews on this repo. Configure here.