Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
21 changes: 21 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,27 @@ export const DIAGNOSTIC_CATEGORY_BUCKETS = [
"Maintainability",
] as const;

// One concrete line per category naming what the user pays if these issues
// ship — the "why going through these is worth it" the diagnostic list can't
// convey from a bare error/warning glyph. Category-level (not per-rule) on
// purpose: KISS, no new rule/diagnostic field, no schema change. `satisfies`
// keeps it exhaustive over the buckets above.
export const CATEGORY_IMPACT = {
Security: "An attacker can read user data, act as your users, or run code you never shipped.",
Bugs: "Real users hit crashes, wrong output, or state that silently corrupts.",
Performance: "Users feel extra latency and wasted renders on every interaction.",
Accessibility: "Users on screen readers, keyboards, or assistive tech get locked out.",
Maintainability: "Every future change gets slower and riskier to make.",
} satisfies Record<(typeof DIAGNOSTIC_CATEGORY_BUCKETS)[number], string>;

// Safe lookup for an arbitrary category string (legacy / adopted rules may
// report categories outside the closed bucket set, so this returns undefined
// rather than indexing blindly).
export const getCategoryImpact = (category: string): string | undefined =>
Object.hasOwn(CATEGORY_IMPACT, category)
? CATEGORY_IMPACT[category as keyof typeof CATEGORY_IMPACT]
: undefined;

// Rules whose heuristic only makes sense in application code. A published
// library deliberately exposes flexible primitives (components built in
// render to capture closures, many `render*` slots for composition), so these
Expand Down
6 changes: 6 additions & 0 deletions packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@
"confbox": "^0.2.4",
"deslop-js": "workspace:*",
"eslint-plugin-react-hooks": "^7.1.1",
"figures": "^6.1.0",
"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 +84,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
94 changes: 94 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,94 @@
import { getCategoryImpact } from "@react-doctor/core";
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;
}

// Body lines hang-indent via container padding (not a leading string) so a
// wrapped continuation line lines up under the first line instead of falling
// back to column 0.
const DETAIL_INDENT_COLUMNS = 4;

/**
* 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;
const impact = getCategoryImpact(row.category);

return (
<Box flexDirection="column">
<Text wrap="truncate-end">
<Text color={variant.color}>
{" "}
{variant.icon}{" "}
</Text>
<Text color={variant.color} bold>
{row.title}
</Text>
{row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null}
</Text>
<Box flexDirection="column" paddingLeft={DETAIL_INDENT_COLUMNS}>
<Text dimColor wrap="truncate-end">
{row.category} · {variant.label}
</Text>
{impact ? (
<Text dimColor wrap="wrap">
{impact}
</Text>
) : null}
<Text wrap="wrap">{representative.message}</Text>
<Text dimColor wrap="truncate-end">
{row.location}
</Text>
</Box>
{codeFrame ? (
<Box
marginTop={1}
marginLeft={DETAIL_INDENT_COLUMNS}
borderStyle="round"
borderColor="gray"
paddingX={1}
alignSelf="flex-start"
>
<Text>{codeFrame}</Text>
</Box>
) : null}
{representative.help ? (
<Box marginTop={1} paddingLeft={DETAIL_INDENT_COLUMNS}>
<Text dimColor wrap="wrap">
→ {representative.help}
</Text>
</Box>
) : null}
{row.learnMore ? (
<Box marginTop={1} paddingLeft={DETAIL_INDENT_COLUMNS}>
<Text color="cyan" wrap="truncate-end">
{row.learnMore}
</Text>
</Box>
) : null}
</Box>
);
};
29 changes: 29 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,29 @@
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, rendered indented under its category header:
* `› ✖ Title ×N` — icon + title colored by severity and a dim `×N` site badge,
* with a `›` pointer on the selected row. The category lives in the header and
* the file location lives in the detail pane, so the row stays uncluttered.
*/
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.title}
</Text>
{row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null}
</Text>
);
};
159 changes: 159 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,159 @@
import { Box, Text, useInput } from "ink";
import { useMemo } from "react";
import type { ReactNode } from "react";
import { useScrollViewport } from "../hooks/use-scroll-viewport.js";
import { buildDiagnosticListEntries } from "../lib/diagnostic-list-entries.js";
import type { DiagnosticListEntry } from "../lib/diagnostic-list-entries.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 type DiagnosticListLayout = "split" | "stacked";

export interface DiagnosticListProps {
/** The score header, rendered above the list (atop the left column in split). */
readonly header: ReactNode;
readonly rows: ReadonlyArray<DiagnosticRow>;
readonly width: number;
/** Left-column width in the split layout. */
readonly listColumnWidth: number;
/** Right-column (detail) width in the split layout. */
readonly detailColumnWidth: number;
readonly listHeight: number;
/** "split" renders the detail beside the list; "stacked" puts it below. */
readonly layout: DiagnosticListLayout;
readonly rootDirectory: string;
/** When set (monorepo flat view), surfaced in the status bar. */
readonly projectCount?: number;
readonly onExit: () => void;
readonly exitHint?: string;
}

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

const renderEntry = (
entry: DiagnosticListEntry,
entryIndex: number,
selectedIndex: number,
): ReactNode => {
if (entry.kind === "header") {
return (
<Text key={`header:${entry.category}`} bold wrap="truncate-end">
{entry.category}
</Text>
);
}
return (
<DiagnosticItem
key={entry.row.ruleKey}
row={entry.row}
isSelected={entryIndex === selectedIndex}
/>
);
};

/**
* The scrollable, category-grouped rule list with a live detail preview — the
* heart of the interactive report. Each category is a bold header line followed
* by its rules, so a row reads as "⚠ Title" instead of repeating the category.
* Scroll/selection (headers skipped) comes from the headless `useScrollViewport`;
* this component is the chrome on top. On a wide terminal the score header + list
* sit in the left column and the detail fills the right column; otherwise the
* detail stacks below.
*/
export const DiagnosticList = ({
header,
rows,
width,
listColumnWidth,
detailColumnWidth,
listHeight,
layout,
rootDirectory,
projectCount,
onExit,
exitHint,
}: DiagnosticListProps) => {
const entries = useMemo(() => buildDiagnosticListEntries(rows), [rows]);
const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({
itemCount: entries.length,
height: listHeight,
isSelectable: (index) => entries[index]?.kind === "item",
});

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

const visibleEntries = entries.slice(visibleStart, visibleEnd);
const selectedEntry = entries[selectedIndex];
const selected = selectedEntry?.kind === "item" ? selectedEntry.row : null;
const errorRows = rows.filter((row) => row.severity === "error");
const warningRows = rows.filter((row) => row.severity === "warning");
// Position among selectable items only (headers don't count toward the count).
const itemPosition = entries
.slice(0, selectedIndex + 1)
.filter((entry) => entry.kind === "item").length;

const isSplit = layout === "split";

const listColumn = (
<Box flexDirection="column" height={listHeight} width={isSplit ? listColumnWidth : width}>
{visibleEntries.map((entry, index) =>
renderEntry(entry, visibleStart + index, selectedIndex),
)}
</Box>
);

const statusBar = (
<Box marginTop={1}>
<StatusBar
total={sumSites(rows)}
errorCount={sumSites(errorRows)}
warningCount={sumSites(warningRows)}
position={rows.length === 0 ? 0 : itemPosition}
groupCount={rows.length}
projectCount={projectCount}
exitHint={exitHint}
/>
</Box>
);

if (isSplit) {
return (
<Box flexDirection="column" width={width}>
<Box flexDirection="row">
<Box flexDirection="column" width={listColumnWidth} marginRight={1}>
{header}
<Box marginTop={1}>{listColumn}</Box>
</Box>
<Box
flexDirection="column"
width={detailColumnWidth}
borderStyle="single"
borderColor="gray"
borderTop={false}
borderRight={false}
borderBottom={false}
paddingLeft={1}
>
<DiagnosticDetail row={selected} rootDirectory={rootDirectory} />
</Box>
</Box>
{statusBar}
</Box>
);
}

return (
<Box flexDirection="column" width={width}>
{header}
<Box marginTop={1}>{listColumn}</Box>
<Text dimColor>{"─".repeat(width)}</Text>
<DiagnosticDetail row={selected} rootDirectory={rootDirectory} />
{statusBar}
</Box>
);
};
Loading
Loading