Skip to content

feat(cli): add experimental interactive Ink TUI#979

Open
aidenybai wants to merge 8 commits into
mainfrom
feat/experimental-ink-tui
Open

feat(cli): add experimental interactive Ink TUI#979
aidenybai wants to merge 8 commits into
mainfrom
feat/experimental-ink-tui

Conversation

@aidenybai

@aidenybai aidenybai commented Jun 27, 2026

Copy link
Copy Markdown
Member

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-tui command) 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-doctor prints the score header + top-3 issues statically; no scroll, no full list, no inline code.

After: react-doctor experimental-tui streams diagnostics live during the scan, then opens a scrollable, score-sorted report:

  • Doctor-face score header with the score bar, score projection ("You could improve +X% by fixing the top N issues") drawn as a ghost-gain segment, and a per-category breakdown.
  • Full rule-group list (no top-3 truncation) with ↑↓/j k, an inline syntax-highlighted code frame for the selected issue, and a status bar.
  • Monorepo support: an interactive project multiselect, plus a multi-project summary (aggregate = worst project's score) with Enter to drill into any project's full report.
  • Concise static footer on exit (scanned files + Share/Docs/GitHub).
  • Ctrl+C force-exits from the interactive views; stdin stays cooked during the scan so a real SIGINT still applies.

What changed

  • New packages/react-doctor/src/cli/ink/**: an external scan-store bridged to React via useSyncExternalStore, the run-scan-app orchestrator (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: optional uiStore that swaps in store-backed Reporter/Progress layers; default path untouched.
  • src/cli/index.ts: registers the hidden experimental-tui command (--no-dead-code, --no-score, -p/--project, -y/--yes); Ink/React are imported lazily so the default path never pays their startup cost.
  • Refactors (DRY, no behavior change): extracted discoverWorkspacePackages from select-projects.ts; extracted shared scoreBandLabel (replacing the private copy in render-multi-project-summary.ts).
  • Build config: Ink/React deps + JSX, externalized ink/react/ink-spinner in the bundle.
  • Tests under tests/ink/** (scan flow, score projection, no-score header, monorepo summary, Ctrl+C handler).

Test plan

  • pnpm --filter react-doctor typecheck
  • pnpm --filter react-doctor lint (no new warnings)
  • pnpm --filter react-doctor format:check
  • Ink test suite + score-header/no-score/colorize units pass locally
  • Full nr 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 result
  • Manual: single-project report, monorepo selection + drill-in, Ctrl+C exit

🤖 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 ScanStore wired into inspect() via optional uiStore and 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/core gains CATEGORY_IMPACT / getCategoryImpact for 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.

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.
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

React Doctor found 6 new issues in 4 files · 6 warnings · score 86 / 100 (Great) · 0 fixed · vs main

6 warnings

src/cli/ink/components/diagnostic-actions.tsx

  • ⚠️ L23 Non-component export in component file only-export-components

src/cli/ink/components/diagnostic-list.tsx

  • ⚠️ L122 Derived value copied into state no-derived-state

src/cli/ink/components/project-select.tsx

  • ⚠️ L59 Array index used as a key no-array-index-as-key
  • ⚠️ L78 Many related useState calls prefer-useReducer
  • ⚠️ L149 Chained array iterations js-combine-iterations

src/cli/ink/components/report.tsx

  • ⚠️ L65 Empty default prop breaks memo rerender-memo-with-default-value

Reviewed by React Doctor for commit b7022f9. See inline comments for fixes.

@pkg-pr-new

pkg-pr-new Bot commented Jun 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@979
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@979
npm i https://pkg.pr.new/react-doctor@979

commit: b7022f9

Comment thread packages/react-doctor/src/cli/ink/scan-app.tsx
Comment thread packages/react-doctor/src/cli/index.ts
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx Outdated
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx
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).
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx Outdated
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx
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.
Comment thread packages/react-doctor/src/cli/ink/components/project-select.tsx
Comment thread packages/react-doctor/src/inspect.ts
…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.
Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx Outdated
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread packages/react-doctor/src/cli/ink/run-scan-app.tsx
projectFlag: options.project,
skipPrompts: options.yes ?? false,
});
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Docs

// toggling it back to unread sticks until the selection moves elsewhere.
useEffect(() => {
if (!selectedRuleKey) return;
setReadRuleKeys((previous) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Docs

<Text bold={isSelected} wrap="truncate-end">
{[...name].map((char, index) =>
matched.has(index) ? (
<Text key={index} color="yellow">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Docs

* 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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Docs

const scanSelection = (): void => {
if (checked.size > 0) {
return submit(
packages.filter((pkg) => checked.has(pkg.directory)).map((pkg) => pkg.directory),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Docs

export const Report = ({
report,
onExit,
launchableAgents = [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Docs

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.

1 participant