Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/experimental-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": minor
---

Add an experimental interactive TUI (`react-doctor experimental-tui`) built on Ink. It streams diagnostics live during the scan, then renders a scrollable, score-sorted report (no top-3 truncation) with the doctor-face score header, score projection ("you could improve +X%"), per-category breakdown, and an inline syntax-highlighted code frame for the selected issue. It also supports monorepo project selection (an interactive multiselect) and a multi-project summary view with drill-in per project. Ink/React load lazily so the default static, JSON, and score-only paths are unaffected.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"smoke:tty-prompt": "python3 scripts/smoke-tty-prompt.py",
"build:schema": "node --experimental-strip-types --no-warnings scripts/generate-config-schema.ts"
},
"dependencies": {
"ink-spinner": "^5.0.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@
"confbox": "^0.2.4",
"deslop-js": "workspace:*",
"eslint-plugin-react-hooks": "^7.1.1",
"ink": "^7.1.0",
"ink-spinner": "^5.0.0",
"jiti": "^2.7.0",
"magicast": "^0.5.3",
"oxlint": ">=1.66.0 <1.67.0",
"oxlint-plugin-react-doctor": "workspace:*",
"prompts": "^2.4.2",
"react": "19.2.5",
"typescript": ">=5.0.4 <6",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.12",
Expand All @@ -80,8 +83,10 @@
"@react-doctor/language-server": "workspace:*",
"@types/babel__code-frame": "^7.27.0",
"@types/prompts": "^2.4.9",
"@types/react": "^19.2.14",
"@xterm/headless": "^6.0.0",
"commander": "^14.0.3",
"ink-testing-library": "^4.0.0",
"ora": "^9.4.0"
},
"engines": {
Expand Down
25 changes: 25 additions & 0 deletions packages/react-doctor/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,31 @@ program
.allowUnknownOption()
.action(() => {});

// Gated behind `experimental-` while the interactive Ink report stabilizes
// (keymap, layout, and the scan-store contract may change). Ink + React load
// lazily here so the default static path never pays their startup cost.
program
.command("experimental-tui [directory]")
.description("[experimental] interactive, scrollable scan report")
.option("--no-dead-code", "skip dead-code analysis")
.option("--no-score", "skip the score API, the share URL, and crash reporting")
.option("-p, --project <names>", "scan specific workspace projects (comma-separated, or *)")
.option("-y, --yes", "skip the project prompt and scan every discovered project")
.action(
async (
directory = ".",
options: { deadCode?: boolean; score?: boolean; project?: string; yes?: boolean },
) => {
const { runScanApp } = await import("./ink/run-scan-app.js");
await runScanApp({
directory,
options: { deadCode: options.deadCode ?? true, noScore: options.score === false },
projectFlag: options.project,
skipPrompts: options.yes ?? false,
});
Comment thread
cursor[bot] marked this conversation as resolved.
},

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.

);

// HACK: when stdout is piped into a process that closes early (e.g.
// `react-doctor . | head`), Node throws an uncaught EPIPE on the next
// write. Exit cleanly instead of dumping a stack trace.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Box, Text } from "ink";
import type { Diagnostic } from "@react-doctor/core";
import { buildCategoryTallies } from "../lib/category-tallies.js";

export interface CategoryBreakdownProps {
readonly diagnostics: ReadonlyArray<Diagnostic>;
}

const pluralize = (count: number, noun: string): string =>
`${count} ${count === 1 ? noun : `${noun}s`}`;

/** The compact "Security › 6 errors, 2 warnings" tally lines, one per category. */
export const CategoryBreakdown = ({ diagnostics }: CategoryBreakdownProps) => {
const tallies = buildCategoryTallies(diagnostics);
if (tallies.length === 0) return null;

return (
<Box flexDirection="column">
{tallies.map((tally) => (
<Text key={tally.category} wrap="truncate-end">
{" "}
<Text bold>{tally.category}</Text>
<Text dimColor> › </Text>
{tally.errorCount > 0 ? (
<Text color="red">{pluralize(tally.errorCount, "error")}</Text>
) : null}
{tally.errorCount > 0 && tally.warningCount > 0 ? <Text dimColor>, </Text> : null}
{tally.warningCount > 0 ? (
<Text color="yellow">{pluralize(tally.warningCount, "warning")}</Text>
) : null}
</Text>
))}
</Box>
);
};
75 changes: 75 additions & 0 deletions packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Box, Text } from "ink";
import { useMemo } from "react";
import { buildCodeFrame } from "../../utils/build-code-frame.js";
import type { DiagnosticRow } from "../lib/diagnostic-rows.js";
import { severityVariant } from "../lib/severity-variants.js";

export interface DiagnosticDetailProps {
readonly row: DiagnosticRow | null;
readonly rootDirectory: string;
}

const INDENT = " ";

/**
* Detail for the selected rule group, styled after the CLI's rule block
* (`render-diagnostics.ts`): icon + headline, impact prose, the dim `→` fix,
* the gray location, and the syntax-highlighted source frame at the site.
*/
export const DiagnosticDetail = ({ row, rootDirectory }: DiagnosticDetailProps) => {
const codeFrame = useMemo(() => {
if (!row) return null;
const { representative } = row;
return buildCodeFrame({
filePath: representative.filePath,
line: representative.line,
column: representative.column,
rootDirectory,
});
}, [row, rootDirectory]);

if (!row) return null;
const variant = severityVariant(row.severity);
const { representative } = row;

return (
<Box flexDirection="column">
<Text wrap="truncate-end">
<Text color={variant.color}>
{" "}
{variant.icon}{" "}
</Text>
<Text color={variant.color} bold>
{row.category}: {row.title}
</Text>
{row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null}
</Text>
<Text wrap="wrap">
{INDENT}
{representative.message}
</Text>
{representative.help ? (
<Text dimColor wrap="wrap">
{INDENT}→ {representative.help}
</Text>
) : null}
<Text dimColor wrap="truncate-end">
{INDENT}
{row.location}
</Text>
{codeFrame ? (
<Box marginTop={1}>
<Text>{codeFrame}</Text>
</Box>
) : null}
{row.learnMore ? (
<Box marginTop={1}>
<Text color="cyan" wrap="truncate-end">
{INDENT}
{row.learnMore}
</Text>
</Box>
) : null}
</Box>
);
};
32 changes: 32 additions & 0 deletions packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Text } from "ink";
import type { DiagnosticRow } from "../lib/diagnostic-rows.js";
import { severityVariant } from "../lib/severity-variants.js";

export interface DiagnosticItemProps {
readonly row: DiagnosticRow;
readonly isSelected: boolean;
}

/**
* One collapsed rule-group line. Mirrors the CLI's rule headline
* (`✖ Category: Title ×N`) — icon + headline colored by severity, a dim
* `×N` site badge, and a gray location — with a `›` pointer on the selected row.
*/
export const DiagnosticItem = ({ row, isSelected }: DiagnosticItemProps) => {
const variant = severityVariant(row.severity);

return (
<Text wrap="truncate-end">
<Text color={isSelected ? variant.color : undefined}>{isSelected ? "› " : " "}</Text>
<Text color={variant.color}>{variant.icon} </Text>
<Text color={variant.color} bold={isSelected}>
{row.category}: {row.title}
</Text>
{row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null}
<Text dimColor>
{" "}
{row.location}
</Text>
</Text>
);
};
72 changes: 72 additions & 0 deletions packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Box, Text, useInput } from "ink";
import { useScrollViewport } from "../hooks/use-scroll-viewport.js";
import type { DiagnosticRow } from "../lib/diagnostic-rows.js";
import { DiagnosticDetail } from "./diagnostic-detail.js";
import { DiagnosticItem } from "./diagnostic-item.js";
import { StatusBar } from "./status-bar.js";

export interface DiagnosticListProps {
readonly rows: ReadonlyArray<DiagnosticRow>;
readonly width: number;
readonly listHeight: number;
readonly rootDirectory: string;
readonly onExit: () => void;
readonly exitHint?: string;
}

const sumSites = (rows: ReadonlyArray<DiagnosticRow>): number =>
rows.reduce((total, row) => total + row.siteCount, 0);

/**
* The scrollable, score-sorted rule-group list with a live detail preview —
* the heart of the interactive report. Scroll/selection logic comes from the
* headless `useScrollViewport`; this component is the chrome on top.
*/
export const DiagnosticList = ({
rows,
width,
listHeight,
rootDirectory,
onExit,
exitHint,
}: DiagnosticListProps) => {
const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({
itemCount: rows.length,
height: listHeight,
});

useInput((input, key) => {
if (input === "q" || key.escape) onExit();
});

const visibleRows = rows.slice(visibleStart, visibleEnd);
const selected = rows[selectedIndex] ?? null;
const errorRows = rows.filter((row) => row.severity === "error");
const warningRows = rows.filter((row) => row.severity === "warning");

return (
<Box flexDirection="column" width={width}>
<Box flexDirection="column" height={listHeight}>
{visibleRows.map((row, index) => (
<DiagnosticItem
key={row.ruleKey}
row={row}
isSelected={visibleStart + index === selectedIndex}
/>
))}
</Box>
<Text dimColor>{"─".repeat(width)}</Text>
<DiagnosticDetail row={selected} rootDirectory={rootDirectory} />
<Box marginTop={1}>
<StatusBar
total={sumSites(rows)}
errorCount={sumSites(errorRows)}
warningCount={sumSites(warningRows)}
position={rows.length === 0 ? 0 : selectedIndex + 1}
groupCount={rows.length}
exitHint={exitHint}
/>
</Box>
</Box>
);
};
107 changes: 107 additions & 0 deletions packages/react-doctor/src/cli/ink/components/project-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import path from "node:path";
import { Box, Text, useApp, useInput } from "ink";
import { useState } from "react";
import type { WorkspacePackage } from "@react-doctor/core";
import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js";
import { useScrollViewport } from "../hooks/use-scroll-viewport.js";
import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js";

export interface ProjectSelectProps {
readonly packages: ReadonlyArray<WorkspacePackage>;
readonly rootDirectory: string;
/** Receives the chosen directories; an empty array means the user cancelled. */
readonly onSubmit: (directories: string[]) => void;
}

const HEADER_ROWS = 2;
const FOOTER_ROWS = 2;
const MIN_LIST_ROWS = 3;

/**
* Interactive multiselect for a monorepo's projects — the Ink replacement for
* the `prompts` multiselect. Space toggles, `a` toggles all, Enter scans the
* selected set (falling back to the highlighted row when none are checked).
*/
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 { rows: terminalRows, columns } = useStdoutDimensions();
const { exit } = useApp();
useExitOnCtrlC();
// Default to all selected: Enter then scans the whole workspace, matching the
// non-interactive "scan all" behavior — deselect to narrow.
const [checked, setChecked] = useState<ReadonlySet<number>>(
() => new Set(packages.map((_, index) => index)),
);

const listHeight = Math.max(MIN_LIST_ROWS, terminalRows - HEADER_ROWS - FOOTER_ROWS);
const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({
itemCount: packages.length,
height: listHeight,
});

useInput((input, key) => {
if (input === " ") {
setChecked((current) => {
const next = new Set(current);
if (next.has(selectedIndex)) next.delete(selectedIndex);
else next.add(selectedIndex);
return next;
});
return;
}
if (input === "a") {
setChecked((current) =>
current.size === packages.length ? new Set() : new Set(packages.map((_, index) => index)),
);
return;
}
if (input === "q") {
exit();
onSubmit([]);
return;
}
// Ink reports Enter via `key.return` (not a literal carriage return), matching
// `useScrollViewport`. Checking the raw char misses Enter on most terminals.
if (key.return) {
const indices = checked.size > 0 ? [...checked] : [selectedIndex];
exit();
onSubmit(indices.map((index) => packages[index].directory));
}
Comment thread
cursor[bot] marked this conversation as resolved.
});

const width = Math.max(24, columns - 2);
const visiblePackages = packages.slice(visibleStart, visibleEnd);

return (
<Box flexDirection="column">
<Text>
<Text bold>Select projects</Text>
<Text dimColor>
{" "}
{checked.size}/{packages.length} selected
</Text>
</Text>
<Box flexDirection="column" height={listHeight} marginTop={1}>
{visiblePackages.map((workspacePackage, index) => {
const packageIndex = visibleStart + index;
const isSelected = packageIndex === selectedIndex;
const isChecked = checked.has(packageIndex);
return (
<Text key={workspacePackage.directory} wrap="truncate-end">
<Text color={isSelected ? "cyan" : undefined}>{isSelected ? "› " : " "}</Text>
<Text color={isChecked ? "green" : undefined}>{isChecked ? "◉ " : "◯ "}</Text>
<Text bold={isSelected}>{workspacePackage.name}</Text>
<Text dimColor>
{" "}
{path.relative(rootDirectory, workspacePackage.directory) || "."}
</Text>
</Text>
);
})}
</Box>
<Text dimColor wrap="truncate-end">
{"─".repeat(width)}
</Text>
<Text dimColor>↑↓ move · space toggle · a all · enter scan · q cancel</Text>
</Box>
);
};
Loading
Loading