From 6e70c9dbd880e2dd2a5ed28bd18d4eccd4be5b10 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 26 Jun 2026 20:31:36 -0700 Subject: [PATCH 1/8] feat(cli): add experimental interactive Ink TUI 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. --- .changeset/experimental-tui.md | 5 + package.json | 3 + packages/react-doctor/package.json | 5 + packages/react-doctor/src/cli/index.ts | 25 ++ .../cli/ink/components/category-breakdown.tsx | 35 ++ .../cli/ink/components/diagnostic-detail.tsx | 75 +++++ .../cli/ink/components/diagnostic-item.tsx | 32 ++ .../cli/ink/components/diagnostic-list.tsx | 72 ++++ .../src/cli/ink/components/project-select.tsx | 105 ++++++ .../src/cli/ink/components/report.tsx | 97 ++++++ .../src/cli/ink/components/scanning.tsx | 44 +++ .../src/cli/ink/components/score-header.tsx | 115 +++++++ .../src/cli/ink/components/status-bar.tsx | 36 ++ .../src/cli/ink/components/summary.tsx | 133 ++++++++ .../src/cli/ink/hooks/use-exit-on-ctrl-c.ts | 23 ++ .../src/cli/ink/hooks/use-scan-store.ts | 6 + .../src/cli/ink/hooks/use-scroll-viewport.ts | 80 +++++ .../cli/ink/hooks/use-stdout-dimensions.ts | 32 ++ .../src/cli/ink/lib/category-tallies.ts | 38 +++ .../src/cli/ink/lib/diagnostic-rows.ts | 52 +++ .../src/cli/ink/lib/score-color.ts | 8 + .../src/cli/ink/lib/severity-variants.ts | 24 ++ .../react-doctor/src/cli/ink/run-scan-app.tsx | 295 +++++++++++++++++ .../react-doctor/src/cli/ink/scan-app.tsx | 34 ++ .../src/cli/ink/scan-bridge-layers.ts | 46 +++ .../react-doctor/src/cli/ink/scan-store.ts | 115 +++++++ .../src/cli/utils/build-runtime-layers.ts | 24 +- .../cli/utils/render-multi-project-summary.ts | 11 +- .../src/cli/utils/score-band-label.ts | 8 + .../src/cli/utils/select-projects.ts | 21 +- packages/react-doctor/src/inspect.ts | 18 +- .../tests/ink/exit-on-ctrl-c.test.tsx | 43 +++ .../react-doctor/tests/ink/ink-smoke.test.tsx | 17 + .../react-doctor/tests/ink/scan-app.test.tsx | 192 +++++++++++ packages/react-doctor/tsconfig.json | 3 +- packages/react-doctor/vite.config.ts | 10 + pnpm-lock.yaml | 308 ++++++++++++++++++ 37 files changed, 2171 insertions(+), 19 deletions(-) create mode 100644 .changeset/experimental-tui.md create mode 100644 packages/react-doctor/src/cli/ink/components/category-breakdown.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/project-select.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/report.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/scanning.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/score-header.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/status-bar.tsx create mode 100644 packages/react-doctor/src/cli/ink/components/summary.tsx create mode 100644 packages/react-doctor/src/cli/ink/hooks/use-exit-on-ctrl-c.ts create mode 100644 packages/react-doctor/src/cli/ink/hooks/use-scan-store.ts create mode 100644 packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts create mode 100644 packages/react-doctor/src/cli/ink/hooks/use-stdout-dimensions.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/category-tallies.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/diagnostic-rows.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/score-color.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/severity-variants.ts create mode 100644 packages/react-doctor/src/cli/ink/run-scan-app.tsx create mode 100644 packages/react-doctor/src/cli/ink/scan-app.tsx create mode 100644 packages/react-doctor/src/cli/ink/scan-bridge-layers.ts create mode 100644 packages/react-doctor/src/cli/ink/scan-store.ts create mode 100644 packages/react-doctor/src/cli/utils/score-band-label.ts create mode 100644 packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx create mode 100644 packages/react-doctor/tests/ink/ink-smoke.test.tsx create mode 100644 packages/react-doctor/tests/ink/scan-app.test.tsx diff --git a/.changeset/experimental-tui.md b/.changeset/experimental-tui.md new file mode 100644 index 000000000..9e0b6d60d --- /dev/null +++ b/.changeset/experimental-tui.md @@ -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. diff --git a/package.json b/package.json index 8cedb98b6..615a8616e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index a64cbd97e..0cbf9e216 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -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", @@ -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": { diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index 32359bbfe..728e28c75 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -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 ", "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, + }); + }, + ); + // 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. diff --git a/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx b/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx new file mode 100644 index 000000000..7df432255 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx @@ -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; +} + +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 ( + + {tallies.map((tally) => ( + + {" "} + {tally.category} + + {tally.errorCount > 0 ? ( + {pluralize(tally.errorCount, "error")} + ) : null} + {tally.errorCount > 0 && tally.warningCount > 0 ? , : null} + {tally.warningCount > 0 ? ( + {pluralize(tally.warningCount, "warning")} + ) : null} + + ))} + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx new file mode 100644 index 000000000..eb500f324 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx @@ -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 ( + + + + {" "} + {variant.icon}{" "} + + + {row.category}: {row.title} + + {row.siteCount > 1 ? ×{row.siteCount} : null} + + + {INDENT} + {representative.message} + + {representative.help ? ( + + {INDENT}→ {representative.help} + + ) : null} + + {INDENT} + {row.location} + + {codeFrame ? ( + + {codeFrame} + + ) : null} + {row.learnMore ? ( + + + {INDENT} + {row.learnMore} + + + ) : null} + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx new file mode 100644 index 000000000..5baea656b --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx @@ -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 ( + + {isSelected ? "› " : " "} + {variant.icon} + + {row.category}: {row.title} + + {row.siteCount > 1 ? ×{row.siteCount} : null} + + {" "} + {row.location} + + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx new file mode 100644 index 000000000..bb06f1d89 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx @@ -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; + readonly width: number; + readonly listHeight: number; + readonly rootDirectory: string; + readonly onExit: () => void; + readonly exitHint?: string; +} + +const sumSites = (rows: ReadonlyArray): 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 ( + + + {visibleRows.map((row, index) => ( + + ))} + + {"─".repeat(width)} + + + + + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/project-select.tsx b/packages/react-doctor/src/cli/ink/components/project-select.tsx new file mode 100644 index 000000000..0da59879a --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/project-select.tsx @@ -0,0 +1,105 @@ +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; + 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) => { + 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>( + () => 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) => { + 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; + } + if (input === "\r") { + const indices = checked.size > 0 ? [...checked] : [selectedIndex]; + exit(); + onSubmit(indices.map((index) => packages[index].directory)); + } + }); + + const width = Math.max(24, columns - 2); + const visiblePackages = packages.slice(visibleStart, visibleEnd); + + return ( + + + Select projects + + {" "} + {checked.size}/{packages.length} selected + + + + {visiblePackages.map((workspacePackage, index) => { + const packageIndex = visibleStart + index; + const isSelected = packageIndex === selectedIndex; + const isChecked = checked.has(packageIndex); + return ( + + {isSelected ? "› " : " "} + {isChecked ? "◉ " : "◯ "} + {workspacePackage.name} + + {" "} + {path.relative(rootDirectory, workspacePackage.directory) || "."} + + + ); + })} + + + {"─".repeat(width)} + + ↑↓ move · space toggle · a all · enter scan · q cancel + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx new file mode 100644 index 000000000..f7dcd9223 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -0,0 +1,97 @@ +import { Box, Text, useInput } from "ink"; +import { useMemo } from "react"; +import type { ScanReport } from "../scan-store.js"; +import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js"; +import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; +import { buildCategoryTallies } from "../lib/category-tallies.js"; +import { buildDiagnosticRows } from "../lib/diagnostic-rows.js"; +import { CategoryBreakdown } from "./category-breakdown.js"; +import { DiagnosticList } from "./diagnostic-list.js"; +import { ScoreHeader } from "./score-header.js"; + +export interface ReportProps { + readonly report: ScanReport; + /** q / Esc handler. In a drill-in (monorepo) this pops back to the summary. */ + readonly onExit: () => void; + /** Hint shown in the empty-state footer (e.g. "Esc back · q quit"). */ + readonly exitHint?: string; +} + +// Score header (face box, 4 lines + trailing blank + the "you could improve" +// line), the detail preview (headline + message + fix + location + a ~7-line +// code frame), the divider, and the status bar — reserved off the terminal +// height so the list gets the rest. Generous so the code frame never clips. +const HEADER_ROWS = 6; +const DETAIL_ROWS = 13; +const STATUS_ROWS = 2; +const DIVIDER_ROWS = 1; +const CHROME_ROWS = HEADER_ROWS + DETAIL_ROWS + STATUS_ROWS + DIVIDER_ROWS + 1; +const MIN_LIST_ROWS = 3; +const MIN_WIDTH = 24; + +/** Full interactive report: score header above the scrollable diagnostics list. */ +export const Report = ({ report, onExit, exitHint = "q quit" }: ReportProps) => { + const { rows: terminalRows, columns } = useStdoutDimensions(); + const diagnosticRows = useMemo( + () => buildDiagnosticRows(report.diagnostics, report.score), + [report.diagnostics, report.score], + ); + const categoryRowCount = useMemo( + () => buildCategoryTallies(report.diagnostics).length, + [report.diagnostics], + ); + + useExitOnCtrlC(); + useInput((input, key) => { + if (input === "q" || key.escape) onExit(); + }); + + const width = Math.max(MIN_WIDTH, columns - 2); + // The category breakdown sits between the header and the list; reserve its + // rows (plus a one-line margin) so the list viewport doesn't overflow. + const breakdownRows = categoryRowCount > 0 ? categoryRowCount + 1 : 0; + const listHeight = Math.max(MIN_LIST_ROWS, terminalRows - CHROME_ROWS - breakdownRows); + + if (diagnosticRows.length === 0) { + return ( + + + + ✔ No issues found. Nice work. + + {exitHint} + + ); + } + + return ( + + + + + + + + + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/scanning.tsx b/packages/react-doctor/src/cli/ink/components/scanning.tsx new file mode 100644 index 000000000..eed43104a --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/scanning.tsx @@ -0,0 +1,44 @@ +import { Box, Text } from "ink"; +import Spinner from "ink-spinner"; +import type { Diagnostic as LiveDiagnostic } from "@react-doctor/core/schemas"; +import { severityVariant } from "../lib/severity-variants.js"; + +export interface ScanningProps { + readonly progressText: string | null; + readonly liveCount: number; + readonly recent: ReadonlyArray; +} + +/** The live scan view: spinner, current phase, running count, and a tail of finds. */ +export const Scanning = ({ progressText, liveCount, recent }: ScanningProps) => { + return ( + + + + {" "} + + {progressText ?? "Scanning…"} + + {" "} + {liveCount} found + + + {recent.map((diagnostic, index) => { + const variant = severityVariant(diagnostic.severity === "error" ? "error" : "warning"); + const location = + diagnostic.line > 0 ? `${diagnostic.filePath}:${diagnostic.line}` : diagnostic.filePath; + return ( + + {" "} + {variant.icon} {diagnostic.title ?? `${diagnostic.plugin}/${diagnostic.rule}`}{" "} + {location} + + ); + })} + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/score-header.tsx b/packages/react-doctor/src/cli/ink/components/score-header.tsx new file mode 100644 index 000000000..184250cfc --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/score-header.tsx @@ -0,0 +1,115 @@ +import { Box, Text } from "ink"; +import { + PERFECT_SCORE, + SCORE_BAR_WIDTH_CHARS, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + TOP_ERRORS_DISPLAY_COUNT, +} from "@react-doctor/core"; +import type { ScoreResult } from "@react-doctor/core"; +import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; +import { scoreColorName } from "../lib/score-color.js"; + +export interface ScoreHeaderProps { + readonly score: ScoreResult | null; + readonly projectedScore: number | null; + readonly projectName: string; + readonly issueCount: number; + /** Shown when `score` is null (e.g. --no-score / API unreachable). */ + readonly noScoreMessage?: string; +} + +// Columns the face box + its gaps eat before the bar starts (2-space indent + +// 7-char box + 2-space gap), mirroring the CLI's SCORE_RIGHT_COLUMN_OFFSET. +const FACE_COLUMN_OFFSET = 11; +const RIGHT_EDGE_SAFETY = 2; + +// The same three doctor faces the CLI draws (`render-score-header.ts`). +const doctorFace = (score: number): readonly [string, string] => { + if (score >= SCORE_GOOD_THRESHOLD) return ["◠ ◠", " ▽ "]; + if (score >= SCORE_OK_THRESHOLD) return ["• •", " ─ "]; + return ["x x", " ▽ "]; +}; + +const BRANDING = "https://react.doctor"; + +export const ScoreHeader = ({ + score, + projectedScore, + projectName, + issueCount, + noScoreMessage, +}: ScoreHeaderProps) => { + const { columns } = useStdoutDimensions(); + + if (!score) { + return ( + + + React Doctor ({BRANDING}) + + {noScoreMessage ?? `${issueCount} issues · ${projectName}`} + + ); + } + + const color = scoreColorName(score.score); + const barWidth = Math.max( + 10, + Math.min(SCORE_BAR_WIDTH_CHARS, columns - FACE_COLUMN_OFFSET - RIGHT_EDGE_SAFETY), + ); + const filled = Math.round((score.score / PERFECT_SCORE) * barWidth); + // The "ghost gain" segment (▓): the points reclaimable by fixing the top + // errors, dimmed in the current fill color — same total width as a plain bar. + const projectedFill = + projectedScore != null + ? Math.min(barWidth, Math.round((projectedScore / PERFECT_SCORE) * barWidth)) + : filled; + const gain = Math.max(0, projectedFill - filled); + const empty = Math.max(0, barWidth - filled - gain); + const [eyes, mouth] = doctorFace(score.score); + + return ( + + + + ┌─────┐ + │ {eyes} │ + │ {mouth} │ + └─────┘ + + + + + {score.score} + + / {PERFECT_SCORE} + {score.label} + + {" · "} + {projectName} + + + + {"█".repeat(filled)} + + {"▓".repeat(gain)} + + {"░".repeat(empty)} + + + React Doctor ({BRANDING}) + + + + + {projectedScore != null && projectedScore > score.score ? ( + + {" You could improve "} + +{projectedScore - score.score}% + {` by fixing the top ${TOP_ERRORS_DISPLAY_COUNT} issues`} + + ) : null} + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/status-bar.tsx b/packages/react-doctor/src/cli/ink/components/status-bar.tsx new file mode 100644 index 000000000..3e3121b72 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/status-bar.tsx @@ -0,0 +1,36 @@ +import { Text } from "ink"; + +export interface StatusBarProps { + readonly total: number; + readonly errorCount: number; + readonly warningCount: number; + readonly position: number; + readonly groupCount: number; + readonly exitHint?: string; +} + +/** Bottom chrome: counts (CLI-style severity coloring), position, and keymap. */ +export const StatusBar = ({ + total, + errorCount, + warningCount, + position, + groupCount, + exitHint = "q quit", +}: StatusBarProps) => ( + + + {total} {total === 1 ? "issue" : "issues"} + + + {errorCount} errors + , + + {warningCount} warnings + + + {" "} + {position}/{groupCount} · ↑↓ move · {exitHint} + + +); diff --git a/packages/react-doctor/src/cli/ink/components/summary.tsx b/packages/react-doctor/src/cli/ink/components/summary.tsx new file mode 100644 index 000000000..a18e83a61 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/summary.tsx @@ -0,0 +1,133 @@ +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import type { MultiProjectSummary } from "../scan-store.js"; +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"; +import { scoreBandLabel } from "../../utils/score-band-label.js"; +import { scoreColorName } from "../lib/score-color.js"; +import { CategoryBreakdown } from "./category-breakdown.js"; +import { Report } from "./report.js"; +import { ScoreHeader } from "./score-header.js"; + +export interface SummaryProps { + readonly summary: MultiProjectSummary; + readonly onExit: () => void; +} + +// Aggregate header (face box + improve line), the combined category breakdown, +// a one-line margin, and the status row — reserved off the terminal height. +const HEADER_ROWS = 7; +const STATUS_ROWS = 2; +const MIN_LIST_ROWS = 3; + +const pluralize = (count: number, noun: string): string => + `${count} ${count === 1 ? noun : `${noun}s`}`; + +/** + * The monorepo aggregate view: the worst project's score, the combined category + * breakdown, and a scrollable project list. Enter drills into a project's full + * report; Esc there pops back here. + */ +export const Summary = ({ summary, onExit }: SummaryProps) => { + const { rows: terminalRows, columns } = useStdoutDimensions(); + const [drilledIndex, setDrilledIndex] = useState(null); + useExitOnCtrlC(); + + const drilled = drilledIndex === null ? null : (summary.projects[drilledIndex] ?? null); + + const categoryRowCount = (() => { + const categories = new Set( + summary.combinedDiagnostics.map((diagnostic) => diagnostic.category), + ); + return categories.size; + })(); + const breakdownRows = categoryRowCount > 0 ? categoryRowCount + 1 : 0; + const listHeight = Math.max( + MIN_LIST_ROWS, + terminalRows - HEADER_ROWS - STATUS_ROWS - breakdownRows, + ); + + const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({ + itemCount: summary.projects.length, + height: listHeight, + isActive: drilled === null, + onActivate: (index) => setDrilledIndex(index), + }); + + // Only quits at the top level; in a drill-in the child `Report` owns Esc/q. + useInput( + (input) => { + if (input === "q") onExit(); + }, + { isActive: drilled === null }, + ); + + if (drilled !== null) { + return setDrilledIndex(null)} exitHint="esc back" />; + } + + const longestNameLength = Math.max( + 0, + ...summary.projects.map((project) => project.projectName.length), + ); + const width = Math.max(24, columns - 2); + const visibleProjects = summary.projects.slice(visibleStart, visibleEnd); + + return ( + + + + + + + {visibleProjects.map((project, index) => { + const isSelected = visibleStart + index === selectedIndex; + const errorCount = project.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ).length; + const warningCount = project.diagnostics.length - errorCount; + const score = project.score?.score ?? null; + return ( + + + {isSelected ? "› " : " "} + + + {project.projectName.padEnd(longestNameLength)} + + {score !== null ? ( + + {" "} + {String(score).padStart(3)} {scoreBandLabel(score)} + + ) : ( + {" no score"} + )} + {" "} + {errorCount > 0 ? {pluralize(errorCount, "error")} : null} + {errorCount > 0 && warningCount > 0 ? , : null} + {warningCount > 0 ? ( + {pluralize(warningCount, "warning")} + ) : null} + + ); + })} + + {"─".repeat(width)} + + {pluralize(summary.projects.length, "project")} + + {" "} + {selectedIndex + 1}/{summary.projects.length} · ↑↓ move · enter open · q quit + + + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/hooks/use-exit-on-ctrl-c.ts b/packages/react-doctor/src/cli/ink/hooks/use-exit-on-ctrl-c.ts new file mode 100644 index 000000000..bc0e0058d --- /dev/null +++ b/packages/react-doctor/src/cli/ink/hooks/use-exit-on-ctrl-c.ts @@ -0,0 +1,23 @@ +import { useApp, useInput } from "ink"; + +const SHOW_CURSOR = "\u001B[?25h"; + +/** + * Force-quits the whole CLI on Ctrl-C from any phase. Ink's built-in + * `exitOnCtrlC` only unmounts the render — during a scan the in-flight + * `inspect()` promise keeps the process alive, so Ctrl-C appears to do nothing. + * Mounting this at the app root makes Ctrl-C always terminate: it restores the + * terminal (raw mode off + cursor back) and exits with the conventional + * 128+SIGINT code so the in-flight scan can't outlive the keystroke. + */ +export const useExitOnCtrlC = (): void => { + const { exit } = useApp(); + useInput((input, key) => { + if (key.ctrl && input === "c") { + exit(); + process.stdin.setRawMode?.(false); + process.stdout.write(SHOW_CURSOR); + process.exit(130); + } + }); +}; diff --git a/packages/react-doctor/src/cli/ink/hooks/use-scan-store.ts b/packages/react-doctor/src/cli/ink/hooks/use-scan-store.ts new file mode 100644 index 000000000..39a2c4afa --- /dev/null +++ b/packages/react-doctor/src/cli/ink/hooks/use-scan-store.ts @@ -0,0 +1,6 @@ +import { useSyncExternalStore } from "react"; +import type { ScanStore, ScanStoreSnapshot } from "../scan-store.js"; + +/** Subscribes the Ink tree to the Effect-driven scan store. */ +export const useScanStore = (store: ScanStore): ScanStoreSnapshot => + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); diff --git a/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts b/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts new file mode 100644 index 000000000..fd4199c35 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts @@ -0,0 +1,80 @@ +import { useInput } from "ink"; +import { useRef, useState } from "react"; + +export interface ScrollViewport { + readonly selectedIndex: number; + /** First visible item index (inclusive). */ + readonly visibleStart: number; + /** Last visible item index (exclusive). */ + readonly visibleEnd: number; +} + +export interface UseScrollViewportOptions { + readonly itemCount: number; + readonly height: number; + readonly isActive?: boolean; + /** Fired on Enter for the selected index. */ + readonly onActivate?: (index: number) => void; +} + +const HALF = 2; + +/** + * Headless scroll + selection over a uniform-height list, with a vim/less-style + * keymap (↑↓ / j k, PgUp·PgDn, Ctrl-u·d half-page, gg·G, Enter). Owns no + * rendering — components read the visible window and the selected index. This is + * the cmdk-style "logic, not chrome" core the `` builds on. + */ +export const useScrollViewport = (options: UseScrollViewportOptions): ScrollViewport => { + const { itemCount, height, isActive = true, onActivate } = options; + const [selectedIndex, setSelectedIndex] = useState(0); + const [offset, setOffset] = useState(0); + const awaitingSecondG = useRef(false); + + const clampIndex = (index: number): number => Math.max(0, Math.min(itemCount - 1, index)); + + const moveTo = (rawIndex: number): void => { + const next = clampIndex(rawIndex); + setSelectedIndex(next); + setOffset((current) => { + if (next < current) return next; + if (next >= current + height) return next - height + 1; + return current; + }); + }; + + useInput( + (input, key) => { + if (itemCount === 0) return; + const isSecondG = awaitingSecondG.current && input === "g"; + if (input !== "g") awaitingSecondG.current = false; + + if (key.downArrow || input === "j") return moveTo(selectedIndex + 1); + if (key.upArrow || input === "k") return moveTo(selectedIndex - 1); + if (key.pageDown) return moveTo(selectedIndex + height); + if (key.pageUp) return moveTo(selectedIndex - height); + if (key.ctrl && input === "d") return moveTo(selectedIndex + Math.floor(height / HALF)); + if (key.ctrl && input === "u") return moveTo(selectedIndex - Math.floor(height / HALF)); + if (input === "G") return moveTo(itemCount - 1); + if (isSecondG) { + awaitingSecondG.current = false; + return moveTo(0); + } + if (input === "g") { + awaitingSecondG.current = true; + return; + } + if (key.return && onActivate) onActivate(clampIndex(selectedIndex)); + }, + { isActive }, + ); + + // Re-clamp every render so a shrinking list can't strand the window past the end. + const maxOffset = Math.max(0, itemCount - height); + const visibleStart = Math.min(offset, maxOffset); + return { + selectedIndex: clampIndex(selectedIndex), + visibleStart, + visibleEnd: Math.min(itemCount, visibleStart + height), + }; +}; diff --git a/packages/react-doctor/src/cli/ink/hooks/use-stdout-dimensions.ts b/packages/react-doctor/src/cli/ink/hooks/use-stdout-dimensions.ts new file mode 100644 index 000000000..75ae6e2a0 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/hooks/use-stdout-dimensions.ts @@ -0,0 +1,32 @@ +import { useStdout } from "ink"; +import { useEffect, useState } from "react"; + +export interface StdoutDimensions { + readonly columns: number; + readonly rows: number; +} + +const DEFAULT_COLUMNS = 80; +const DEFAULT_ROWS = 24; + +const readDimensions = (stdout: NodeJS.WriteStream | undefined): StdoutDimensions => ({ + columns: stdout?.columns ?? DEFAULT_COLUMNS, + rows: stdout?.rows ?? DEFAULT_ROWS, +}); + +/** Live terminal size, re-reading on `resize` so the layout reflows. */ +export const useStdoutDimensions = (): StdoutDimensions => { + const { stdout } = useStdout(); + const [dimensions, setDimensions] = useState(() => readDimensions(stdout)); + + useEffect(() => { + if (!stdout) return; + const onResize = (): void => setDimensions(readDimensions(stdout)); + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + + return dimensions; +}; diff --git a/packages/react-doctor/src/cli/ink/lib/category-tallies.ts b/packages/react-doctor/src/cli/ink/lib/category-tallies.ts new file mode 100644 index 000000000..69d0f708b --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/category-tallies.ts @@ -0,0 +1,38 @@ +import { DIAGNOSTIC_CATEGORY_BUCKETS } from "@react-doctor/core"; +import type { Diagnostic } from "@react-doctor/core"; + +/** One category row in the breakdown: its error / warning counts. */ +export interface CategoryTally { + readonly category: string; + readonly errorCount: number; + readonly warningCount: number; +} + +// Fixed display order (Security first), mirroring the CLI's category breakdown +// so the reader scans to a category by position, not by the day's weighting. +const CATEGORY_RANK = new Map( + DIAGNOSTIC_CATEGORY_BUCKETS.map((category, index) => [category, index]), +); + +const rankOf = (category: string): number => CATEGORY_RANK.get(category) ?? Number.MAX_SAFE_INTEGER; + +/** + * Groups diagnostics into per-category error / warning tallies, ordered by the + * canonical category display rank (unknown categories sort last, alphabetically). + */ +export const buildCategoryTallies = (diagnostics: ReadonlyArray): CategoryTally[] => { + const tallyByCategory = new Map(); + for (const diagnostic of diagnostics) { + const tally = tallyByCategory.get(diagnostic.category) ?? { errorCount: 0, warningCount: 0 }; + if (diagnostic.severity === "error") tally.errorCount += 1; + else tally.warningCount += 1; + tallyByCategory.set(diagnostic.category, tally); + } + + return [...tallyByCategory.entries()] + .map(([category, counts]) => ({ category, ...counts })) + .sort((tallyA, tallyB) => { + const rankDelta = rankOf(tallyA.category) - rankOf(tallyB.category); + return rankDelta !== 0 ? rankDelta : tallyA.category.localeCompare(tallyB.category); + }); +}; diff --git a/packages/react-doctor/src/cli/ink/lib/diagnostic-rows.ts b/packages/react-doctor/src/cli/ink/lib/diagnostic-rows.ts new file mode 100644 index 000000000..488b32f31 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/diagnostic-rows.ts @@ -0,0 +1,52 @@ +import type { Diagnostic, ScoreResult } from "@react-doctor/core"; +import { + buildRulePriorityMap, + buildSortedRuleGroups, + formatLearnMoreLine, +} from "../../utils/diagnostic-grouping.js"; +import type { Severity } from "./severity-variants.js"; + +/** One scannable row in the diagnostics list: a fully-sorted rule group. */ +export interface DiagnosticRow { + readonly ruleKey: string; + readonly diagnostics: ReadonlyArray; + readonly severity: Severity; + readonly category: string; + readonly title: string; + /** `file:line` for the representative site (or just `file` when line-less). */ + readonly location: string; + readonly siteCount: number; + readonly representative: Diagnostic; + readonly learnMore: string | null; +} + +const pickRepresentative = (diagnostics: ReadonlyArray): Diagnostic => + diagnostics.find((diagnostic) => diagnostic.line > 0) ?? diagnostics[0]; + +const formatLocation = (diagnostic: Diagnostic): string => + diagnostic.line > 0 ? `${diagnostic.filePath}:${diagnostic.line}` : diagnostic.filePath; + +/** + * Projects the settled diagnostics into the full, score-priority-sorted list of + * rule-group rows — no top-N truncation. The TUI viewport handles the volume. + */ +export const buildDiagnosticRows = ( + diagnostics: ReadonlyArray, + score: ScoreResult | null, +): DiagnosticRow[] => { + const rulePriority = buildRulePriorityMap([score]); + return buildSortedRuleGroups(diagnostics, rulePriority).map(([ruleKey, ruleDiagnostics]) => { + const representative = pickRepresentative(ruleDiagnostics); + return { + ruleKey, + diagnostics: ruleDiagnostics, + severity: representative.severity === "error" ? "error" : "warning", + category: representative.category, + title: representative.title ?? ruleKey, + location: formatLocation(representative), + siteCount: ruleDiagnostics.length, + representative, + learnMore: formatLearnMoreLine(representative), + }; + }); +}; diff --git a/packages/react-doctor/src/cli/ink/lib/score-color.ts b/packages/react-doctor/src/cli/ink/lib/score-color.ts new file mode 100644 index 000000000..450b63f36 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/score-color.ts @@ -0,0 +1,8 @@ +import { SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from "@react-doctor/core"; + +/** The Ink color name for a score, matching the CLI's green/yellow/red bands. */ +export const scoreColorName = (score: number): string => { + if (score >= SCORE_GOOD_THRESHOLD) return "green"; + if (score >= SCORE_OK_THRESHOLD) return "yellow"; + return "red"; +}; diff --git a/packages/react-doctor/src/cli/ink/lib/severity-variants.ts b/packages/react-doctor/src/cli/ink/lib/severity-variants.ts new file mode 100644 index 000000000..13933924a --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/severity-variants.ts @@ -0,0 +1,24 @@ +import isUnicodeSupported from "is-unicode-supported"; + +export type Severity = "error" | "warning"; + +export interface SeverityVariant { + /** Ink `` value. */ + readonly color: string; + readonly icon: string; + readonly label: string; +} + +const ICONS = isUnicodeSupported() + ? ({ error: "✖", warning: "⚠" } as const) + : ({ error: "x", warning: "!" } as const); + +/** + * The `cva`-style single source of severity styling: maps a diagnostic + * severity onto the Ink `` color, glyph, and label so components + * stay declarative instead of scattering severity ternaries. + */ +export const severityVariant = (severity: Severity): SeverityVariant => + severity === "error" + ? { color: "red", icon: ICONS.error, label: "error" } + : { color: "yellow", icon: ICONS.warning, label: "warning" }; diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx new file mode 100644 index 000000000..7fe6cbf86 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -0,0 +1,295 @@ +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { render } from "ink"; +import * as Effect from "effect/Effect"; +import { + DEFAULT_PROJECT_SCAN_CONCURRENCY, + highlighter, + mapWithConcurrency, +} from "@react-doctor/core"; +import type { Diagnostic, InspectResult, ScoreResult, WorkspacePackage } from "@react-doctor/core"; +import { inspect } from "../../inspect.js"; +import type { ReactDoctorInspectOptions } from "../../inspect.js"; +import { buildNoScoreMessage } from "../utils/build-no-score-message.js"; +import { computeProjectedScore } from "../utils/compute-score-projection.js"; +import { discoverWorkspacePackages, selectProjects } from "../utils/select-projects.js"; +import { isCiOrCodingAgentEnvironment } from "../utils/is-ci-environment.js"; +import { formatElapsedTime } from "../utils/render-diagnostics.js"; +import { printFooter } from "../utils/render-summary.js"; +import { ProjectSelect } from "./components/project-select.js"; +import { ScanApp } from "./scan-app.js"; +import { createScanStore } from "./scan-store.js"; +import type { MultiProjectSummary, ScanReport } from "./scan-store.js"; + +export interface RunScanAppInput { + readonly directory: string; + readonly options?: ReactDoctorInspectOptions; + /** `--project` value (comma list or `*`); resolves without the interactive prompt. */ + readonly projectFlag?: string; + /** `-y`/`--yes`: skip the prompt and scan every discovered project. */ + readonly skipPrompts?: boolean; + /** Persistent `projects` from the user's config — the flag's declared form. */ + readonly configProjects?: readonly string[]; +} + +export interface RunScanAppResult { + readonly errorCount: number; + readonly warningCount: number; +} + +const countBySeverity = (diagnostics: ReadonlyArray, severity: string): number => + diagnostics.filter((diagnostic) => diagnostic.severity === severity).length; + +// The share URL is suppressed for --no-score and in CI / coding-agent runs, +// mirroring the CLI's `shouldShowShareLink` gate. +const resolveIsOffline = (options: ReactDoctorInspectOptions | undefined): boolean => + options?.noScore === true || isCiOrCodingAgentEnvironment(); + +/** Resolves the directories to scan, prompting via Ink only when truly interactive. */ +const resolveSelectedDirectories = async ( + rootDirectory: string, + input: RunScanAppInput, +): Promise => { + const packages = discoverWorkspacePackages(rootDirectory); + const needsPrompt = + packages.length > 1 && + !input.projectFlag && + !input.skipPrompts && + (input.configProjects ?? []).length === 0 && + process.stdin.isTTY === true; + + if (!needsPrompt) { + return selectProjects( + rootDirectory, + input.projectFlag, + input.skipPrompts ?? false, + input.configProjects, + ); + } + + return promptProjectSelection(packages, rootDirectory); +}; + +const promptProjectSelection = ( + packages: ReadonlyArray, + rootDirectory: string, +): Promise => + new Promise((resolve) => { + const instance = render( + { + instance.unmount(); + resolve(directories); + }} + />, + { exitOnCtrlC: false }, + ); + }); + +interface ScanReportInput { + readonly result: InspectResult; + readonly rootDirectory: string; + readonly projectedScore: number | null; + readonly isOffline: boolean; + readonly noScoreMessage: string; +} + +const toScanReport = ({ + result, + rootDirectory, + projectedScore, + isOffline, + noScoreMessage, +}: ScanReportInput): ScanReport => ({ + diagnostics: result.diagnostics, + score: result.score, + projectedScore, + projectName: result.project.projectName, + rootDirectory, + scannedFileCount: result.scannedFileCount ?? 0, + elapsedMilliseconds: result.elapsedMilliseconds, + isOffline, + noScoreMessage, +}); + +// The aggregate score for a monorepo is its WORST project's (a chain is only as +// strong as its weakest link), so the projection is computed against it too. +const findLowestScored = ( + reports: ReadonlyArray<{ score: ScoreResult | null; diagnostics: ReadonlyArray }>, +): { score: ScoreResult; diagnostics: ReadonlyArray } | null => { + let worst: { score: ScoreResult; diagnostics: ReadonlyArray } | null = null; + for (const report of reports) { + if (report.score === null) continue; + if (worst === null || report.score.score < worst.score.score) { + worst = { score: report.score, diagnostics: report.diagnostics }; + } + } + return worst; +}; + +interface ExitFooterInput { + readonly diagnostics: ReadonlyArray; + readonly scoreResult: ScoreResult | null; + readonly projectName: string; + readonly scannedFileCount: number; + readonly elapsedMilliseconds: number; + readonly isOffline: boolean; +} + +const printExitFooter = async (input: ExitFooterInput): Promise => { + const fileLabel = input.scannedFileCount === 1 ? "file" : "files"; + process.stdout.write( + `${highlighter.success("✔")} Scanned ${input.scannedFileCount} ${fileLabel} in ${formatElapsedTime(input.elapsedMilliseconds)}\n`, + ); + await Effect.runPromise( + printFooter({ + diagnostics: [...input.diagnostics], + scoreResult: input.scoreResult, + projectName: input.projectName, + isOffline: input.isOffline, + }), + ); +}; + +const runSingleProjectScan = async ( + directory: string, + input: RunScanAppInput, +): Promise => { + const store = createScanStore(); + const instance = render(, { exitOnCtrlC: false }); + const isOffline = resolveIsOffline(input.options); + const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); + + try { + const result = await inspect(directory, { ...input.options, uiStore: store }); + const projectedScore = result.score + ? await computeProjectedScore([...result.diagnostics], [...result.diagnostics], result.score) + : null; + store.setReport( + toScanReport({ result, rootDirectory: directory, projectedScore, isOffline, noScoreMessage }), + ); + await instance.waitUntilExit(); + await printExitFooter({ + diagnostics: result.diagnostics, + scoreResult: result.score, + projectName: result.project.projectName, + scannedFileCount: result.scannedFileCount ?? 0, + elapsedMilliseconds: result.elapsedMilliseconds, + isOffline, + }); + return { + errorCount: countBySeverity(result.diagnostics, "error"), + warningCount: countBySeverity(result.diagnostics, "warning"), + }; + } catch (error) { + instance.unmount(); + throw error; + } +}; + +const runMultiProjectScan = async ( + rootDirectory: string, + directories: ReadonlyArray, + input: RunScanAppInput, +): Promise => { + const store = createScanStore(); + const instance = render(, { exitOnCtrlC: false }); + const isOffline = resolveIsOffline(input.options); + const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); + + try { + const startTime = performance.now(); + let finishedCount = 0; + store.setProgress({ + text: `Scanning ${directories.length} projects…`, + status: "active", + }); + const results = await mapWithConcurrency( + [...directories], + DEFAULT_PROJECT_SCAN_CONCURRENCY, + async (projectDirectory) => { + const result = await inspect(projectDirectory, { + ...input.options, + suppressRendering: true, + concurrentScan: true, + }); + finishedCount += 1; + store.setProgress({ + text: `Scanning ${directories.length} projects… (${finishedCount}/${directories.length})`, + status: "active", + }); + return { directory: projectDirectory, result }; + }, + ); + + const projects = results.map(({ directory, result }) => + toScanReport({ + result, + rootDirectory: directory, + projectedScore: null, + isOffline, + noScoreMessage, + }), + ); + const combinedDiagnostics = projects.flatMap((project) => [...project.diagnostics]); + const worst = findLowestScored(projects); + const projectedScore = worst + ? await computeProjectedScore(combinedDiagnostics, [...worst.diagnostics], worst.score) + : null; + const scannedFileCount = results.reduce( + (total, { result }) => total + (result.scannedFileCount ?? 0), + 0, + ); + const elapsedMilliseconds = performance.now() - startTime; + + const summary: MultiProjectSummary = { + projects, + aggregateScore: worst?.score ?? null, + projectedScore, + combinedDiagnostics, + scannedFileCount, + elapsedMilliseconds, + projectName: path.basename(rootDirectory), + isOffline, + noScoreMessage, + }; + store.setSummary(summary); + await instance.waitUntilExit(); + await printExitFooter({ + diagnostics: combinedDiagnostics, + scoreResult: summary.aggregateScore, + projectName: summary.projectName, + scannedFileCount, + elapsedMilliseconds, + isOffline, + }); + return { + errorCount: countBySeverity(combinedDiagnostics, "error"), + warningCount: countBySeverity(combinedDiagnostics, "warning"), + }; + } catch (error) { + instance.unmount(); + throw error; + } +}; + +/** + * Entry point for the interactive Ink scan UI. Discovers and (when interactive) + * prompts for the workspace projects to scan, then mounts the live scan view and + * routes to the single-project report or the monorepo summary once settled. On + * exit it prints a concise static footer (scanned files + Share / Docs / GitHub). + */ +export const runScanApp = async (input: RunScanAppInput): Promise => { + const rootDirectory = path.resolve(input.directory); + const selectedDirectories = await resolveSelectedDirectories(rootDirectory, input); + + if (selectedDirectories.length === 0) { + return { errorCount: 0, warningCount: 0 }; + } + if (selectedDirectories.length === 1) { + return runSingleProjectScan(selectedDirectories[0], input); + } + return runMultiProjectScan(rootDirectory, selectedDirectories, input); +}; diff --git a/packages/react-doctor/src/cli/ink/scan-app.tsx b/packages/react-doctor/src/cli/ink/scan-app.tsx new file mode 100644 index 000000000..595f81172 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/scan-app.tsx @@ -0,0 +1,34 @@ +import { useApp } from "ink"; +import { Report } from "./components/report.js"; +import { Scanning } from "./components/scanning.js"; +import { Summary } from "./components/summary.js"; +import { useScanStore } from "./hooks/use-scan-store.js"; +import type { ScanStore } from "./scan-store.js"; + +export interface ScanAppProps { + readonly store: ScanStore; +} + +const RECENT_LIVE_COUNT = 8; + +/** Root of the interactive scan UI: routes the store phase to a view. */ +export const ScanApp = ({ store }: ScanAppProps) => { + const snapshot = useScanStore(store); + const { exit } = useApp(); + + if (snapshot.phase === "summary" && snapshot.summary) { + return exit()} />; + } + + if (snapshot.phase === "report" && snapshot.report) { + return exit()} />; + } + + return ( + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/scan-bridge-layers.ts b/packages/react-doctor/src/cli/ink/scan-bridge-layers.ts new file mode 100644 index 000000000..3f7cf97eb --- /dev/null +++ b/packages/react-doctor/src/cli/ink/scan-bridge-layers.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Progress, Reporter } from "@react-doctor/core"; +import type { ProgressHandle } from "@react-doctor/core"; +import type { ScanStore } from "./scan-store.js"; + +/** + * `Reporter` side-channel that forwards every fully-filtered diagnostic + * (across all sources, post per-element pipeline) into the scan store as the + * orchestrator emits it — the live feed the Ink app renders while the scan + * runs. The final sorted/scored list still rides the orchestrator's return + * value, so `finalize` is a no-op here. + */ +export const reporterLayerForStore = (store: ScanStore): Layer.Layer => + Layer.succeed( + Reporter, + Reporter.of({ + emit: (diagnostic) => Effect.sync(() => store.emitDiagnostic(diagnostic)), + finalize: Effect.void, + }), + ); + +/** + * `ProgressHandle` factory backed by the scan store, fed to the existing + * `Progress.layerOra` slot. Maps the orchestrator's spinner lifecycle onto + * store progress state the Ink spinner reads, replacing the ora terminal + * writes on the interactive path. + */ +export const progressHandleForStore = + (store: ScanStore) => + (text: string): ProgressHandle => { + store.setProgress({ text, status: "active" }); + return { + update: (displayText) => + Effect.sync(() => store.setProgress({ text: displayText, status: "active" })), + succeed: (displayText) => + Effect.sync(() => store.setProgress({ text: displayText, status: "succeeded" })), + fail: (displayText) => + Effect.sync(() => store.setProgress({ text: displayText, status: "failed" })), + stop: () => Effect.sync(() => store.setProgress(null)), + }; + }; + +/** The store-backed `Progress` layer, reusing the injected-factory slot. */ +export const progressLayerForStore = (store: ScanStore): Layer.Layer => + Progress.layerOra(progressHandleForStore(store)); diff --git a/packages/react-doctor/src/cli/ink/scan-store.ts b/packages/react-doctor/src/cli/ink/scan-store.ts new file mode 100644 index 000000000..9e1c7368e --- /dev/null +++ b/packages/react-doctor/src/cli/ink/scan-store.ts @@ -0,0 +1,115 @@ +import type { Diagnostic, ScoreResult } from "@react-doctor/core"; +// The live feed carries diagnostics exactly as `Reporter.emit` produces them +// (the schema class), which differs from the index `Diagnostic` type only in +// nested-array readonly-ness. The settled `report` keeps the index type. +import type { Diagnostic as LiveDiagnostic } from "@react-doctor/core/schemas"; + +export type ScanPhase = "scanning" | "report" | "summary" | "done"; + +export type ProgressStatus = "active" | "succeeded" | "failed"; + +export interface ProgressState { + readonly text: string; + readonly status: ProgressStatus; +} + +/** The settled single-project scan output the report view renders. */ +export interface ScanReport { + readonly diagnostics: ReadonlyArray; + readonly score: ScoreResult | null; + /** Score reachable by fixing the top errors (the bar's ghost gain), or null. */ + readonly projectedScore: number | null; + readonly projectName: string; + /** Absolute scan root, used to read source for the inline code frame. */ + readonly rootDirectory: string; + readonly scannedFileCount: number; + readonly elapsedMilliseconds: number; + /** True when the share URL is suppressed (--no-score / share off / CI). */ + readonly isOffline: boolean; + /** Shown in place of the score header when `score` is null. */ + readonly noScoreMessage: string; +} + +/** A monorepo scan: per-project reports plus the aggregate (worst) score. */ +export interface MultiProjectSummary { + readonly projects: ReadonlyArray; + /** The worst project's score — a chain is only as strong as its weakest link. */ + readonly aggregateScore: ScoreResult | null; + readonly projectedScore: number | null; + /** Every project's diagnostics, for the combined category breakdown + share. */ + readonly combinedDiagnostics: ReadonlyArray; + readonly scannedFileCount: number; + readonly elapsedMilliseconds: number; + readonly projectName: string; + readonly isOffline: boolean; + readonly noScoreMessage: string; +} + +export interface ScanStoreSnapshot { + readonly phase: ScanPhase; + /** Diagnostics as the orchestrator emits them, before the final sort/score. */ + readonly liveDiagnostics: ReadonlyArray; + readonly liveCount: number; + /** Latest scan-progress line, or `null` once the scan stops without a result. */ + readonly progress: ProgressState | null; + /** Settled single-project output, present once a single-project scan resolves. */ + readonly report: ScanReport | null; + /** Settled monorepo output, present once a multi-project scan resolves. */ + readonly summary: MultiProjectSummary | null; +} + +/** + * The single boundary between the Effect-driven scan and the Ink render tree. + * Writers run on the Effect side (the store-backed `Reporter` / `Progress` + * layers and the command after `inspect()` resolves); the Ink app reads the + * immutable snapshot through React's `useSyncExternalStore`. Each writer swaps + * the snapshot reference so React only re-renders on real changes. + */ +export interface ScanStore { + readonly subscribe: (listener: () => void) => () => void; + readonly getSnapshot: () => ScanStoreSnapshot; + readonly emitDiagnostic: (diagnostic: LiveDiagnostic) => void; + readonly setProgress: (progress: ProgressState | null) => void; + readonly setReport: (report: ScanReport) => void; + readonly setSummary: (summary: MultiProjectSummary) => void; + readonly setPhase: (phase: ScanPhase) => void; +} + +const INITIAL_SNAPSHOT: ScanStoreSnapshot = { + phase: "scanning", + liveDiagnostics: [], + liveCount: 0, + progress: null, + report: null, + summary: null, +}; + +export const createScanStore = (): ScanStore => { + let snapshot = INITIAL_SNAPSHOT; + const listeners = new Set<() => void>(); + + const commit = (next: ScanStoreSnapshot): void => { + snapshot = next; + for (const listener of listeners) listener(); + }; + + return { + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => snapshot, + emitDiagnostic: (diagnostic) => + commit({ + ...snapshot, + liveDiagnostics: [...snapshot.liveDiagnostics, diagnostic], + liveCount: snapshot.liveCount + 1, + }), + setProgress: (progress) => commit({ ...snapshot, progress }), + setReport: (report) => commit({ ...snapshot, report, phase: "report" }), + setSummary: (summary) => commit({ ...snapshot, summary, phase: "summary" }), + setPhase: (phase) => commit({ ...snapshot, phase }), + }; +}; diff --git a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts index 50471586c..675a1c220 100644 --- a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts +++ b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts @@ -58,6 +58,19 @@ export interface BuildRuntimeLayersInput { * count) in place. */ readonly oxlintConcurrency?: number; + /** + * Optional `Reporter` override. The interactive Ink UI supplies a + * store-backed reporter so every fully-filtered diagnostic streams into the + * live scan view; left `undefined`, the production path stays `layerNoop` + * (the final report rides `inspect()`'s return value). + */ + readonly reporterLayer?: Layer.Layer; + /** + * Optional `Progress` override. The Ink UI supplies a store-backed progress + * layer in place of the ora spinner so the scan's lifecycle drives the + * in-app spinner instead of writing escape codes over the render tree. + */ + readonly progressLayer?: Layer.Layer; } /** @@ -115,9 +128,12 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { input.projectInfoOverride === undefined ? Project.layerNode : Project.layerOf(input.projectInfoOverride); - const progressLayer = input.shouldShowProgressSpinners - ? Progress.layerOra(buildSpinnerProgressHandle) - : Progress.layerNoop; + const progressLayer = + input.progressLayer ?? + (input.shouldShowProgressSpinners + ? Progress.layerOra(buildSpinnerProgressHandle) + : Progress.layerNoop); + const reporterLayer = input.reporterLayer ?? Reporter.layerNoop; const configLayer = input.hasConfigOverride ? Config.layerOf({ config: input.userConfig, @@ -140,7 +156,7 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { LintPartialFailures.layerLive, deadCodeLayer, progressLayer, - Reporter.layerNoop, + reporterLayer, scoreLayer, supplyChainLayer, ); diff --git a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts index ba6eda091..1d928e812 100644 --- a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts @@ -1,8 +1,9 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { highlighter, SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from "@react-doctor/core"; +import { highlighter } from "@react-doctor/core"; import type { Diagnostic, InspectResult, ScoreResult } from "@react-doctor/core"; import { colorizeByScore } from "./colorize-by-score.js"; +import { scoreBandLabel } from "./score-band-label.js"; import { filterScansForSurface } from "./filter-scans-for-surface.js"; import type { SurfaceFilterableScan } from "./filter-scans-for-surface.js"; import { computeProjectedScore } from "./compute-score-projection.js"; @@ -21,12 +22,6 @@ interface ProjectScanEntry { readonly errorCount: number; } -const getScoreLabel = (score: number): string => { - if (score >= SCORE_GOOD_THRESHOLD) return "Great"; - if (score >= SCORE_OK_THRESHOLD) return "OK"; - return "Needs work"; -}; - const buildSummaryLine = (entry: ProjectScanEntry, longestProjectNameLength: number): string => { const paddedName = entry.projectName.padEnd(longestProjectNameLength); const nameRendering = @@ -38,7 +33,7 @@ const buildSummaryLine = (entry: ProjectScanEntry, longestProjectNameLength: num } const scoreRendering = colorizeByScore(String(entry.score).padStart(3), entry.score); - const label = colorizeByScore(getScoreLabel(entry.score), entry.score); + const label = colorizeByScore(scoreBandLabel(entry.score), entry.score); const issuesParts: string[] = []; if (entry.errorCount > 0) { diff --git a/packages/react-doctor/src/cli/utils/score-band-label.ts b/packages/react-doctor/src/cli/utils/score-band-label.ts new file mode 100644 index 000000000..da6f0f779 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/score-band-label.ts @@ -0,0 +1,8 @@ +import { SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from "@react-doctor/core"; + +/** The short qualitative label for a score, shown next to the number. */ +export const scoreBandLabel = (score: number): string => { + if (score >= SCORE_GOOD_THRESHOLD) return "Great"; + if (score >= SCORE_OK_THRESHOLD) return "OK"; + return "Needs work"; +}; diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index e277a62fd..957fa9b0e 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -14,17 +14,28 @@ import { METRIC } from "./constants.js"; import { prompts } from "./prompts.js"; import { recordCount } from "./record-metric.js"; +/** + * The workspace packages a scan would offer to select from: explicit workspace + * members, or — for a bare/monorepo root with none declared — discovered React + * subprojects. Shared by `selectProjects` and the interactive Ink runner so both + * agree on what "the projects" are without duplicating the discovery rules. + */ +export const discoverWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { + const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); + const packages = listWorkspacePackages(rootDirectory); + if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) { + return discoverReactSubprojects(rootDirectory); + } + return packages; +}; + export const selectProjects = async ( rootDirectory: string, projectFlag: string | undefined, skipPrompts: boolean, configProjects?: readonly string[], ): Promise => { - const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); - let packages = listWorkspacePackages(rootDirectory); - if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) { - packages = discoverReactSubprojects(rootDirectory); - } + const packages = discoverWorkspacePackages(rootDirectory); // The flag wins over workspace discovery: entries can name packages OR // point at arbitrary directories, so it must resolve even when discovery diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index c4e8e1a9f..e071c3a69 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -17,6 +17,8 @@ import { } from "@react-doctor/core"; import { applyObservability } from "./cli/utils/apply-observability.js"; import { buildRuntimeLayers } from "./cli/utils/build-runtime-layers.js"; +import { progressLayerForStore, reporterLayerForStore } from "./cli/ink/scan-bridge-layers.js"; +import type { ScanStore } from "./cli/ink/scan-store.js"; import { recordSentryProjectContext, resetSentryRunState, @@ -137,6 +139,12 @@ const buildChangedLineMatcher = ( export interface ReactDoctorInspectOptions extends InspectOptions { categoryFilters?: string[]; + /** + * CLI-only: when present, the scan streams live diagnostics + progress into + * this store (for the interactive Ink UI) and suppresses all console + * rendering — the Ink app owns the screen and reads the returned result. + */ + uiStore?: ScanStore; } export interface ResolvedInspectOptions { @@ -175,6 +183,8 @@ export interface ResolvedInspectOptions { changedLineRanges: ReadonlyArray | null; /** See `InspectOptions.supplyChainManifestChanged`. */ supplyChainManifestChanged: boolean; + /** Interactive Ink UI store, or `null` for the static console path. */ + uiStore: ScanStore | null; } const buildIgnoredTags = (userConfig: ReactDoctorConfig | null): ReadonlySet => { @@ -209,7 +219,10 @@ const mergeInspectOptions = ( adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true, ignoredTags: buildIgnoredTags(userConfig), outputSurface: inputOptions.outputSurface ?? "cli", - suppressRendering: inputOptions.suppressRendering ?? false, + // The Ink UI owns the screen, so it suppresses console rendering exactly like + // a multi-project batch does — the difference is the live store feed. + suppressRendering: (inputOptions.suppressRendering ?? false) || inputOptions.uiStore != null, + uiStore: inputOptions.uiStore ?? null, concurrentScan: inputOptions.concurrentScan ?? false, concurrency: inputOptions.concurrency, baseline: inputOptions.baseline ?? null, @@ -554,6 +567,8 @@ const runInspectWithRuntime = async ( shouldComputeScore: !options.noScore, shouldShowProgressSpinners, oxlintConcurrency: options.concurrency, + reporterLayer: options.uiStore ? reporterLayerForStore(options.uiStore) : undefined, + progressLayer: options.uiStore ? progressLayerForStore(options.uiStore) : undefined, }); const program = runInspectEffect( @@ -629,6 +644,7 @@ const runInspectWithRuntime = async ( // `message.includes(...)`). if ( !options.scoreOnly && + !options.uiStore && !lintBindingMissing && output.didLintFail && lintFailureReason !== null diff --git a/packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx b/packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx new file mode 100644 index 000000000..efc2e9262 --- /dev/null +++ b/packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx @@ -0,0 +1,43 @@ +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { useExitOnCtrlC } from "../../src/cli/ink/hooks/use-exit-on-ctrl-c.js"; + +const Harness = () => { + useExitOnCtrlC(); + return ready; +}; + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); + +describe("useExitOnCtrlC", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("force-exits with code 130 on Ctrl+C", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + const { stdin, unmount } = render(); + await flush(); + + stdin.write("\u0003"); + await flush(); + + expect(exitSpy).toHaveBeenCalledWith(130); + unmount(); + }); + + it("ignores other keys", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + const { stdin, unmount } = render(); + await flush(); + + stdin.write("j"); + await flush(); + + expect(exitSpy).not.toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/packages/react-doctor/tests/ink/ink-smoke.test.tsx b/packages/react-doctor/tests/ink/ink-smoke.test.tsx new file mode 100644 index 000000000..0849a5fa4 --- /dev/null +++ b/packages/react-doctor/tests/ink/ink-smoke.test.tsx @@ -0,0 +1,17 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vite-plus/test"; +import { severityVariant } from "../../src/cli/ink/lib/severity-variants.js"; + +describe("ink toolchain", () => { + it("renders JSX to a frame", () => { + const { lastFrame } = render( + + + {severityVariant("error").icon} hello doctor + + , + ); + expect(lastFrame()).toContain("hello doctor"); + }); +}); diff --git a/packages/react-doctor/tests/ink/scan-app.test.tsx b/packages/react-doctor/tests/ink/scan-app.test.tsx new file mode 100644 index 000000000..0185fe620 --- /dev/null +++ b/packages/react-doctor/tests/ink/scan-app.test.tsx @@ -0,0 +1,192 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vite-plus/test"; +import type { Diagnostic, ScoreResult } from "@react-doctor/core"; +import { ScanApp } from "../../src/cli/ink/scan-app.js"; +import { createScanStore } from "../../src/cli/ink/scan-store.js"; + +const makeDiagnostic = (overrides: Partial): Diagnostic => ({ + filePath: "src/Profile.tsx", + plugin: "react-doctor", + rule: "no-derived-state-effect", + severity: "warning", + message: "Your users briefly see stale state on every prop change.", + help: "", + line: 1, + column: 1, + category: "State & Effects", + ...overrides, +}); + +const SCORE: ScoreResult = { score: 72, label: "Fair" }; + +// ink-testing-library needs a tick for effects (useInput wiring) to flush. +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); + +describe("ScanApp", () => { + it("renders the live scan view before a report settles", () => { + const store = createScanStore(); + store.setProgress({ text: "Linting source files", status: "active" }); + store.emitDiagnostic(makeDiagnostic({ rule: "rules-of-hooks", severity: "error" })); + + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("Linting source files"); + expect(frame).toContain("1 found"); + unmount(); + }); + + it("renders the score header and the full sorted rule list once settled", () => { + const store = createScanStore(); + const diagnostics = [ + makeDiagnostic({ rule: "rules-of-hooks", severity: "error", category: "Correctness" }), + makeDiagnostic({ rule: "no-array-index-key", filePath: "src/Cart.tsx", line: 9 }), + makeDiagnostic({ rule: "no-array-index-key", filePath: "src/List.tsx", line: 4 }), + ]; + store.setReport({ + diagnostics, + score: SCORE, + projectedScore: null, + projectName: "demo-app", + rootDirectory: "/tmp/demo-app", + scannedFileCount: 12, + elapsedMilliseconds: 1234, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("72"); + expect(frame).toContain("demo-app"); + // No `title` on the test diagnostics → the row falls back to `plugin/rule`. + expect(frame).toContain("Correctness: react-doctor/rules-of-hooks"); + // The second rule groups its two sites into one row with a count badge. + expect(frame).toContain("×2"); + unmount(); + }); + + it("shows the score projection and per-category breakdown", () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [ + makeDiagnostic({ rule: "rules-of-hooks", severity: "error", category: "Correctness" }), + makeDiagnostic({ rule: "no-array-index-key", severity: "warning", category: "Bugs" }), + ], + score: SCORE, + projectedScore: 88, + projectName: "demo-app", + rootDirectory: "/tmp/demo-app", + scannedFileCount: 3, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ""; + expect(frame).toContain("You could improve"); + expect(frame).toContain("+16%"); + expect(frame).toContain("Correctness"); + expect(frame).toContain("Bugs"); + unmount(); + }); + + it("renders the no-score header when the score is unavailable", () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [makeDiagnostic({ rule: "rules-of-hooks", severity: "error" })], + score: null, + projectedScore: null, + projectName: "demo-app", + rootDirectory: "/tmp/demo-app", + scannedFileCount: 1, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score disabled by --no-score.", + }); + + const { lastFrame, unmount } = render(); + expect(lastFrame() ?? "").toContain("Score disabled by --no-score."); + unmount(); + }); + + it("renders the monorepo summary with aggregate score and project rows", () => { + const store = createScanStore(); + const webReport = { + diagnostics: [makeDiagnostic({ rule: "rules-of-hooks", severity: "error" })], + score: { score: 58, label: "Needs work" } as ScoreResult, + projectedScore: null, + projectName: "web", + rootDirectory: "/tmp/repo/apps/web", + scannedFileCount: 4, + elapsedMilliseconds: 5, + isOffline: true, + noScoreMessage: "Score unavailable.", + }; + const apiReport = { + diagnostics: [makeDiagnostic({ rule: "no-array-index-key", severity: "warning" })], + score: { score: 91, label: "Great" } as ScoreResult, + projectedScore: null, + projectName: "api", + rootDirectory: "/tmp/repo/apps/api", + scannedFileCount: 6, + elapsedMilliseconds: 5, + isOffline: true, + noScoreMessage: "Score unavailable.", + }; + store.setSummary({ + projects: [webReport, apiReport], + aggregateScore: webReport.score, + projectedScore: null, + combinedDiagnostics: [...webReport.diagnostics, ...apiReport.diagnostics], + scannedFileCount: 10, + elapsedMilliseconds: 12, + projectName: "repo", + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, unmount } = render(); + const frame = lastFrame() ?? ""; + // Aggregate score is the worst project's (58, not 91). + expect(frame).toContain("58"); + expect(frame).toContain("web"); + expect(frame).toContain("api"); + expect(frame).toContain("2 projects"); + unmount(); + }); + + it("moves the selection with j/k and quits on q", async () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [ + makeDiagnostic({ rule: "rules-of-hooks", severity: "error", category: "Correctness" }), + makeDiagnostic({ rule: "no-array-index-key", filePath: "src/Cart.tsx", line: 9 }), + ], + score: SCORE, + projectedScore: null, + projectName: "demo-app", + rootDirectory: "/tmp/demo-app", + scannedFileCount: 2, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, stdin, unmount } = render(); + await flush(); + + // First row selected by default → detail pane shows the first rule's message. + expect(lastFrame() ?? "").toContain("Correctness: react-doctor/rules-of-hooks"); + + stdin.write("j"); + await flush(); + // After moving down, the detail pane reflects the second rule. + expect(lastFrame() ?? "").toContain("no-array-index-key"); + + // `q` is handled without throwing (exit is wired through useApp()). + stdin.write("q"); + await flush(); + unmount(); + }); +}); diff --git a/packages/react-doctor/tsconfig.json b/packages/react-doctor/tsconfig.json index a2c6b84d5..18ba9031b 100644 --- a/packages/react-doctor/tsconfig.json +++ b/packages/react-doctor/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "noEmit": true, - "declarationMap": true + "declarationMap": true, + "jsx": "react-jsx" }, "include": ["src"] } diff --git a/packages/react-doctor/vite.config.ts b/packages/react-doctor/vite.config.ts index e968a74d4..f4f017318 100644 --- a/packages/react-doctor/vite.config.ts +++ b/packages/react-doctor/vite.config.ts @@ -116,6 +116,16 @@ export default defineConfig({ "oxlint-plugin-react-doctor", "prompts", "typescript", + // The interactive Ink report (lazy-loaded for `experimental-tui`). + // Ink pulls `yoga-layout` (a wasm module) and React resolves + // `react/jsx-runtime` via the automatic JSX transform — both break + // when inlined, so keep them external and let Node resolve them from + // node_modules on install. + "ink", + "ink-spinner", + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", ], }, dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b65d11392..0141bff8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,10 @@ overrides: importers: .: + dependencies: + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@7.1.0(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) devDependencies: '@changesets/changelog-github': specifier: ^0.7.0 @@ -223,6 +227,12 @@ importers: eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) + ink: + specifier: ^7.1.0 + version: 7.1.0(@types/react@19.2.14)(react@19.2.5) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@7.1.0(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) jiti: specifier: ^2.7.0 version: 2.7.0 @@ -238,6 +248,9 @@ importers: prompts: specifier: ^2.4.2 version: 2.4.2 + react: + specifier: 19.2.5 + version: 19.2.5 typescript: specifier: '>=5.0.4 <6' version: 5.9.3 @@ -269,12 +282,18 @@ importers: '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 '@xterm/headless': specifier: ^6.0.0 version: 6.0.0 commander: specifier: ^14.0.3 version: 14.0.3 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.2.14) ora: specifier: ^9.4.0 version: 9.4.0 @@ -337,6 +356,10 @@ importers: packages: + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -2688,6 +2711,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2700,6 +2727,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -2723,6 +2754,10 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2785,17 +2820,37 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-spinners@3.4.0: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-truncate@6.0.0: + resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} + engines: {node: '>=22'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2823,6 +2878,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -2892,12 +2951,19 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -2917,6 +2983,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3071,6 +3141,10 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3128,18 +3202,60 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + ini@7.0.0: resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@7.1.0: + resolution: {integrity: sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -3341,6 +3457,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3436,6 +3556,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3516,6 +3640,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3604,6 +3732,12 @@ packages: peerDependencies: react: ^19.2.5 + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -3628,6 +3762,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -3682,6 +3820,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3697,6 +3838,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3714,6 +3859,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3728,6 +3877,10 @@ packages: resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} engines: {node: '>=20'} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3782,6 +3935,10 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + terser@5.46.0: resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} engines: {node: '>=10'} @@ -4017,10 +4174,18 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -4049,6 +4214,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -4060,6 +4228,11 @@ packages: snapshots: + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@alloc/quick-lru@5.2.0': {} '@ark/schema@0.56.0': @@ -5718,6 +5891,10 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -5726,6 +5903,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -5751,6 +5930,8 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 + auto-bind@5.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -5806,14 +5987,31 @@ snapshots: cjs-module-lexer@2.2.0: {} + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@6.0.0: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.1 + client-only@0.0.1: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5843,6 +6041,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -5909,10 +6109,14 @@ snapshots: env-paths@3.0.0: {} + environment@1.1.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.1.0: {} + es-toolkit@1.48.1: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -6002,6 +6206,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(jiti@2.7.0)): @@ -6176,6 +6382,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-east-asian-width@1.6.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6233,14 +6441,66 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@5.0.0: {} + ini@7.0.0: {} + ink-spinner@5.0.0(ink@7.1.0(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): + dependencies: + cli-spinners: 2.9.2 + ink: 7.1.0(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + + ink-testing-library@4.0.0(@types/react@19.2.14): + optionalDependencies: + '@types/react': 19.2.14 + + ink@7.1.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.48.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.5 + react-reconciler: 0.33.0(react@19.2.5) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.6.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.0 + ws: 8.20.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + is-extglob@2.1.1: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ci@2.0.0: {} + is-interactive@2.0.0: {} is-number@7.0.0: {} @@ -6394,6 +6654,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} minimatch@10.2.5: @@ -6480,6 +6742,10 @@ snapshots: obug@2.1.1: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -6670,6 +6936,8 @@ snapshots: dependencies: callsites: 3.1.0 + patch-console@2.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -6735,6 +7003,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + react@19.2.5: {} read-yaml-file@1.1.0: @@ -6757,6 +7030,11 @@ snapshots: resolve-from@5.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -6853,6 +7131,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sirv@3.0.2: @@ -6865,6 +7145,11 @@ snapshots: slash@3.0.0: {} + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6883,6 +7168,10 @@ snapshots: sprintf-js@1.0.3: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@4.0.0: {} @@ -6894,6 +7183,11 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6929,6 +7223,8 @@ snapshots: term-size@2.2.1: {} + terminal-size@4.0.1: {} + terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -7158,8 +7454,18 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + word-wrap@1.2.5: {} + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.1.2 + ws@8.20.0: {} yallist@3.1.1: {} @@ -7170,6 +7476,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 From b0c28d663142b5befa1a0f3828baeac9a611f0ef Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 26 Jun 2026 20:50:49 -0700 Subject: [PATCH 2/8] fix(cli): honor config projects and share gate in experimental TUI 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). --- .../react-doctor/src/cli/ink/run-scan-app.tsx | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index 7fe6cbf86..f8d12dc69 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -6,6 +6,7 @@ import { DEFAULT_PROJECT_SCAN_CONCURRENCY, highlighter, mapWithConcurrency, + resolveScanTarget, } from "@react-doctor/core"; import type { Diagnostic, InspectResult, ScoreResult, WorkspacePackage } from "@react-doctor/core"; import { inspect } from "../../inspect.js"; @@ -28,8 +29,16 @@ export interface RunScanAppInput { readonly projectFlag?: string; /** `-y`/`--yes`: skip the prompt and scan every discovered project. */ readonly skipPrompts?: boolean; - /** Persistent `projects` from the user's config — the flag's declared form. */ + /** + * Persistent `projects` from the user's config. When omitted, `runScanApp` + * loads it from the resolved scan target (parity with the static CLI). + */ readonly configProjects?: readonly string[]; + /** + * Whether the Share URL may be printed (the config's `share`, default true). + * When omitted, `runScanApp` loads it from the resolved scan target. + */ + readonly share?: boolean; } export interface RunScanAppResult { @@ -40,10 +49,10 @@ export interface RunScanAppResult { const countBySeverity = (diagnostics: ReadonlyArray, severity: string): number => diagnostics.filter((diagnostic) => diagnostic.severity === severity).length; -// The share URL is suppressed for --no-score and in CI / coding-agent runs, -// mirroring the CLI's `shouldShowShareLink` gate. -const resolveIsOffline = (options: ReactDoctorInspectOptions | undefined): boolean => - options?.noScore === true || isCiOrCodingAgentEnvironment(); +// The share URL is suppressed for --no-score, `share: false` in config, and in +// CI / coding-agent runs, mirroring the CLI's `shouldShowShareLink` gate. +const resolveIsOffline = (input: RunScanAppInput): boolean => + input.options?.noScore === true || input.share === false || isCiOrCodingAgentEnvironment(); /** Resolves the directories to scan, prompting via Ink only when truly interactive. */ const resolveSelectedDirectories = async ( @@ -159,7 +168,7 @@ const runSingleProjectScan = async ( ): Promise => { const store = createScanStore(); const instance = render(, { exitOnCtrlC: false }); - const isOffline = resolveIsOffline(input.options); + const isOffline = resolveIsOffline(input); const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); try { @@ -196,7 +205,7 @@ const runMultiProjectScan = async ( ): Promise => { const store = createScanStore(); const instance = render(, { exitOnCtrlC: false }); - const isOffline = resolveIsOffline(input.options); + const isOffline = resolveIsOffline(input); const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); try { @@ -282,14 +291,23 @@ const runMultiProjectScan = async ( * exit it prints a concise static footer (scanned files + Share / Docs / GitHub). */ export const runScanApp = async (input: RunScanAppInput): Promise => { - const rootDirectory = path.resolve(input.directory); - const selectedDirectories = await resolveSelectedDirectories(rootDirectory, input); + // Resolve the scan target once so the TUI honors the same on-disk config the + // static CLI does: the `rootDir` redirect, the persistent `projects` list, and + // the `share` gate (each only filled in when the caller didn't pass it). + const scanTarget = await resolveScanTarget(input.directory); + const rootDirectory = scanTarget.resolvedDirectory; + const resolvedInput: RunScanAppInput = { + ...input, + configProjects: input.configProjects ?? scanTarget.userConfig?.projects, + share: input.share ?? scanTarget.userConfig?.share ?? true, + }; + const selectedDirectories = await resolveSelectedDirectories(rootDirectory, resolvedInput); if (selectedDirectories.length === 0) { return { errorCount: 0, warningCount: 0 }; } if (selectedDirectories.length === 1) { - return runSingleProjectScan(selectedDirectories[0], input); + return runSingleProjectScan(selectedDirectories[0], resolvedInput); } - return runMultiProjectScan(rootDirectory, selectedDirectories, input); + return runMultiProjectScan(rootDirectory, selectedDirectories, resolvedInput); }; From a5b4c4cafd09acab5849feab6e0c8e5e0c8553c7 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 26 Jun 2026 21:10:43 -0700 Subject: [PATCH 3/8] fix(cli): dedupe scanned-file count in experimental TUI monorepo scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../react-doctor/src/cli/ink/run-scan-app.tsx | 8 +++---- .../cli/utils/count-unique-scanned-files.ts | 21 +++++++++++++++++ .../cli/utils/render-multi-project-summary.ts | 23 ++++--------------- 3 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 packages/react-doctor/src/cli/utils/count-unique-scanned-files.ts diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index f8d12dc69..4efcdfffc 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -13,6 +13,7 @@ import { inspect } from "../../inspect.js"; import type { ReactDoctorInspectOptions } from "../../inspect.js"; import { buildNoScoreMessage } from "../utils/build-no-score-message.js"; import { computeProjectedScore } from "../utils/compute-score-projection.js"; +import { countUniqueScannedFiles } from "../utils/count-unique-scanned-files.js"; import { discoverWorkspacePackages, selectProjects } from "../utils/select-projects.js"; import { isCiOrCodingAgentEnvironment } from "../utils/is-ci-environment.js"; import { formatElapsedTime } from "../utils/render-diagnostics.js"; @@ -247,10 +248,9 @@ const runMultiProjectScan = async ( const projectedScore = worst ? await computeProjectedScore(combinedDiagnostics, [...worst.diagnostics], worst.score) : null; - const scannedFileCount = results.reduce( - (total, { result }) => total + (result.scannedFileCount ?? 0), - 0, - ); + // Dedupe by absolute path so nested workspace packages don't double-count + // shared files — parity with the static monorepo summary. + const scannedFileCount = countUniqueScannedFiles(results.map(({ result }) => result)); const elapsedMilliseconds = performance.now() - startTime; const summary: MultiProjectSummary = { diff --git a/packages/react-doctor/src/cli/utils/count-unique-scanned-files.ts b/packages/react-doctor/src/cli/utils/count-unique-scanned-files.ts new file mode 100644 index 000000000..c38cbd38f --- /dev/null +++ b/packages/react-doctor/src/cli/utils/count-unique-scanned-files.ts @@ -0,0 +1,21 @@ +import type { InspectResult } from "@react-doctor/core"; + +// Count UNIQUE scanned files by absolute path: nested workspace packages (a +// parent whose tree contains a child package) scan the shared files in BOTH +// projects, so naively summing per-project counts overstates the real total. A +// scan that reported no file paths can't be deduped, so it contributes its own +// reported count (this fallback is per-scan, not all-or-nothing — the other +// projects still dedupe against each other). +export const countUniqueScannedFiles = (results: ReadonlyArray): number => { + const uniqueScannedFilePaths = new Set(); + let fileCountFromScansWithoutPaths = 0; + for (const result of results) { + const scannedFilePaths = result.scannedFilePaths; + if (scannedFilePaths && scannedFilePaths.length > 0) { + for (const filePath of scannedFilePaths) uniqueScannedFilePaths.add(filePath); + } else { + fileCountFromScansWithoutPaths += result.scannedFileCount ?? result.project.sourceFileCount; + } + } + return uniqueScannedFilePaths.size + fileCountFromScansWithoutPaths; +}; diff --git a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts index 1d928e812..b80746e9a 100644 --- a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import { highlighter } from "@react-doctor/core"; import type { Diagnostic, InspectResult, ScoreResult } from "@react-doctor/core"; import { colorizeByScore } from "./colorize-by-score.js"; +import { countUniqueScannedFiles } from "./count-unique-scanned-files.js"; import { scoreBandLabel } from "./score-band-label.js"; import { filterScansForSurface } from "./filter-scans-for-surface.js"; import type { SurfaceFilterableScan } from "./filter-scans-for-surface.js"; @@ -112,25 +113,9 @@ export const printMultiProjectSummary = (input: MultiProjectSummaryInput): Effec // through a bounded concurrent pool, so the caller passes the // wall-clock total rather than summing per-project durations. // - // Count UNIQUE scanned files by absolute path: nested workspace - // packages (a parent whose tree contains a child package) scan the - // shared files in BOTH projects, so naively summing per-project - // counts overstates the real total. A scan that reported no file - // paths can't be deduped, so it contributes its own reported count - // (this fallback is per-scan, not all-or-nothing — the other - // projects still dedupe against each other). - const uniqueScannedFilePaths = new Set(); - let fileCountFromScansWithoutPaths = 0; - for (const scan of completedScans) { - const scannedFilePaths = scan.result.scannedFilePaths; - if (scannedFilePaths && scannedFilePaths.length > 0) { - for (const filePath of scannedFilePaths) uniqueScannedFilePaths.add(filePath); - } else { - fileCountFromScansWithoutPaths += - scan.result.scannedFileCount ?? scan.result.project.sourceFileCount; - } - } - const totalScannedFileCount = uniqueScannedFilePaths.size + fileCountFromScansWithoutPaths; + const totalScannedFileCount = countUniqueScannedFiles( + completedScans.map((scan) => scan.result), + ); yield* Console.log( `${highlighter.success("✔")} Scanned ${totalScannedFileCount} ${totalScannedFileCount === 1 ? "file" : "files"} in ${formatElapsedTime(totalElapsedMilliseconds)}`, ); From 6465f097c1011fecb8178242eafadf06fd463dbc Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 26 Jun 2026 21:25:20 -0700 Subject: [PATCH 4/8] fix(cli): use key.return for project-select Enter and surface TUI lint failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/cli/ink/components/project-select.tsx | 6 ++-- .../react-doctor/src/cli/ink/run-scan-app.tsx | 18 +++++++++++ .../tests/ink/project-select.test.tsx | 30 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 packages/react-doctor/tests/ink/project-select.test.tsx diff --git a/packages/react-doctor/src/cli/ink/components/project-select.tsx b/packages/react-doctor/src/cli/ink/components/project-select.tsx index 0da59879a..8c21cbdda 100644 --- a/packages/react-doctor/src/cli/ink/components/project-select.tsx +++ b/packages/react-doctor/src/cli/ink/components/project-select.tsx @@ -38,7 +38,7 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele height: listHeight, }); - useInput((input) => { + useInput((input, key) => { if (input === " ") { setChecked((current) => { const next = new Set(current); @@ -59,7 +59,9 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele onSubmit([]); return; } - if (input === "\r") { + // 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)); diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index 4efcdfffc..e17de5b50 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -146,13 +146,29 @@ interface ExitFooterInput { readonly scannedFileCount: number; readonly elapsedMilliseconds: number; readonly isOffline: boolean; + /** The reason lint was skipped, if it failed — surfaced like the static CLI does. */ + readonly lintFailureReason: string | null; } +// The Ink report never shows the post-scan lint-failure hint (it's suppressed +// when `uiStore` is active), so the exit footer surfaces it instead — otherwise +// a TUI report could look clean while oxlint silently failed. +const resolveLintFailureReason = (results: ReadonlyArray): string | null => { + for (const result of results) { + const reason = result.skippedCheckReasons?.lint; + if (reason) return reason; + } + return null; +}; + const printExitFooter = async (input: ExitFooterInput): Promise => { const fileLabel = input.scannedFileCount === 1 ? "file" : "files"; process.stdout.write( `${highlighter.success("✔")} Scanned ${input.scannedFileCount} ${fileLabel} in ${formatElapsedTime(input.elapsedMilliseconds)}\n`, ); + if (input.lintFailureReason !== null) { + process.stdout.write(`${highlighter.warn("⚠")} Lint did not run: ${input.lintFailureReason}\n`); + } await Effect.runPromise( printFooter({ diagnostics: [...input.diagnostics], @@ -188,6 +204,7 @@ const runSingleProjectScan = async ( scannedFileCount: result.scannedFileCount ?? 0, elapsedMilliseconds: result.elapsedMilliseconds, isOffline, + lintFailureReason: resolveLintFailureReason([result]), }); return { errorCount: countBySeverity(result.diagnostics, "error"), @@ -273,6 +290,7 @@ const runMultiProjectScan = async ( scannedFileCount, elapsedMilliseconds, isOffline, + lintFailureReason: resolveLintFailureReason(results.map(({ result }) => result)), }); return { errorCount: countBySeverity(combinedDiagnostics, "error"), diff --git a/packages/react-doctor/tests/ink/project-select.test.tsx b/packages/react-doctor/tests/ink/project-select.test.tsx new file mode 100644 index 000000000..68d94639f --- /dev/null +++ b/packages/react-doctor/tests/ink/project-select.test.tsx @@ -0,0 +1,30 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { WorkspacePackage } from "@react-doctor/core"; +import { ProjectSelect } from "../../src/cli/ink/components/project-select.js"; + +const PACKAGES: WorkspacePackage[] = [ + { name: "web", directory: "/repo/apps/web" }, + { name: "docs", directory: "/repo/apps/docs" }, +]; + +// ink-testing-library needs a tick for effects (useInput wiring) to flush. +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); + +describe("ProjectSelect", () => { + it("submits the selected directories when Enter is pressed", async () => { + const onSubmit = vi.fn(); + const { stdin, unmount } = render( + , + ); + await flush(); + + // Enter arrives as a carriage return that Ink normalizes to `key.return`. + stdin.write("\r"); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(["/repo/apps/web", "/repo/apps/docs"]); + unmount(); + }); +}); From 181e47a5624fc8b701f2a03c30448d7be4be1e97 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 26 Jun 2026 21:37:02 -0700 Subject: [PATCH 5/8] fix(cli): match static share-link gate (CI only) in experimental TUI 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. --- packages/react-doctor/src/cli/ink/run-scan-app.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index e17de5b50..d6ca90ffb 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -15,7 +15,7 @@ import { buildNoScoreMessage } from "../utils/build-no-score-message.js"; import { computeProjectedScore } from "../utils/compute-score-projection.js"; import { countUniqueScannedFiles } from "../utils/count-unique-scanned-files.js"; import { discoverWorkspacePackages, selectProjects } from "../utils/select-projects.js"; -import { isCiOrCodingAgentEnvironment } from "../utils/is-ci-environment.js"; +import { isCiEnvironment } from "../utils/is-ci-environment.js"; import { formatElapsedTime } from "../utils/render-diagnostics.js"; import { printFooter } from "../utils/render-summary.js"; import { ProjectSelect } from "./components/project-select.js"; @@ -51,9 +51,10 @@ const countBySeverity = (diagnostics: ReadonlyArray, severity: strin diagnostics.filter((diagnostic) => diagnostic.severity === severity).length; // The share URL is suppressed for --no-score, `share: false` in config, and in -// CI / coding-agent runs, mirroring the CLI's `shouldShowShareLink` gate. +// CI, mirroring the CLI's `shouldShowShareLink` gate exactly (CI only — it does +// not additionally gate on coding-agent environments). const resolveIsOffline = (input: RunScanAppInput): boolean => - input.options?.noScore === true || input.share === false || isCiOrCodingAgentEnvironment(); + input.options?.noScore === true || input.share === false || isCiEnvironment(); /** Resolves the directories to scan, prompting via Ink only when truly interactive. */ const resolveSelectedDirectories = async ( From d46a322eebf1544ebc23d0a19fd3c00cae72022c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 27 Jun 2026 23:12:39 -0700 Subject: [PATCH 6/8] fix --- packages/core/src/constants.ts | 21 ++ packages/react-doctor/package.json | 1 + .../cli/ink/components/category-breakdown.tsx | 35 --- .../cli/ink/components/diagnostic-detail.tsx | 53 ++-- .../cli/ink/components/diagnostic-item.tsx | 13 +- .../cli/ink/components/diagnostic-list.tsx | 137 +++++++-- .../src/cli/ink/components/project-select.tsx | 288 ++++++++++++++---- .../src/cli/ink/components/report.tsx | 115 ++++--- .../src/cli/ink/components/scanning.tsx | 28 +- .../src/cli/ink/components/score-header.tsx | 26 +- .../src/cli/ink/components/status-bar.tsx | 9 + .../src/cli/ink/components/summary.tsx | 140 ++------- .../src/cli/ink/hooks/use-animated-score.ts | 85 ++++++ .../src/cli/ink/hooks/use-scroll-viewport.ts | 65 +++- .../src/cli/ink/lib/category-tallies.ts | 38 --- .../cli/ink/lib/diagnostic-list-entries.ts | 63 ++++ .../src/cli/ink/lib/fuzzy-match.ts | 52 ++++ .../react-doctor/src/cli/ink/run-scan-app.tsx | 32 +- .../react-doctor/src/cli/ink/scan-app.tsx | 2 +- .../react-doctor/src/cli/ink/scan-store.ts | 2 + packages/react-doctor/src/inspect.ts | 8 +- .../tests/ink/diagnostic-list-entries.test.ts | 59 ++++ .../tests/ink/fuzzy-match.test.ts | 29 ++ .../tests/ink/project-select.test.tsx | 98 +++++- .../react-doctor/tests/ink/scan-app.test.tsx | 94 +++++- pnpm-lock.yaml | 11 + 26 files changed, 1098 insertions(+), 406 deletions(-) delete mode 100644 packages/react-doctor/src/cli/ink/components/category-breakdown.tsx create mode 100644 packages/react-doctor/src/cli/ink/hooks/use-animated-score.ts delete mode 100644 packages/react-doctor/src/cli/ink/lib/category-tallies.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/diagnostic-list-entries.ts create mode 100644 packages/react-doctor/src/cli/ink/lib/fuzzy-match.ts create mode 100644 packages/react-doctor/tests/ink/diagnostic-list-entries.test.ts create mode 100644 packages/react-doctor/tests/ink/fuzzy-match.test.ts diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 7330cbc24..db5add84b 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 0cbf9e216..ecd173357 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -63,6 +63,7 @@ "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", diff --git a/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx b/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx deleted file mode 100644 index 7df432255..000000000 --- a/packages/react-doctor/src/cli/ink/components/category-breakdown.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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; -} - -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 ( - - {tallies.map((tally) => ( - - {" "} - {tally.category} - - {tally.errorCount > 0 ? ( - {pluralize(tally.errorCount, "error")} - ) : null} - {tally.errorCount > 0 && tally.warningCount > 0 ? , : null} - {tally.warningCount > 0 ? ( - {pluralize(tally.warningCount, "warning")} - ) : null} - - ))} - - ); -}; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx index eb500f324..428e5af5c 100644 --- a/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-detail.tsx @@ -1,3 +1,4 @@ +import { getCategoryImpact } from "@react-doctor/core"; import { Box, Text } from "ink"; import { useMemo } from "react"; import { buildCodeFrame } from "../../utils/build-code-frame.js"; @@ -9,7 +10,10 @@ export interface DiagnosticDetailProps { readonly rootDirectory: string; } -const INDENT = " "; +// 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 @@ -31,6 +35,7 @@ export const DiagnosticDetail = ({ row, rootDirectory }: DiagnosticDetailProps) if (!row) return null; const variant = severityVariant(row.severity); const { representative } = row; + const impact = getCategoryImpact(row.category); return ( @@ -40,32 +45,46 @@ export const DiagnosticDetail = ({ row, rootDirectory }: DiagnosticDetailProps) {variant.icon}{" "} - {row.category}: {row.title} + {row.title} {row.siteCount > 1 ? ×{row.siteCount} : null} - - {INDENT} - {representative.message} - - {representative.help ? ( - - {INDENT}→ {representative.help} + + + {row.category} · {variant.label} - ) : null} - - {INDENT} - {row.location} - + {impact ? ( + + {impact} + + ) : null} + {representative.message} + + {row.location} + + {codeFrame ? ( - + {codeFrame} ) : null} + {representative.help ? ( + + + → {representative.help} + + + ) : null} {row.learnMore ? ( - + - {INDENT} {row.learnMore} diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx index 5baea656b..2dbceeaa0 100644 --- a/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx @@ -8,9 +8,10 @@ export interface DiagnosticItemProps { } /** - * 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. + * 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); @@ -20,13 +21,9 @@ export const DiagnosticItem = ({ row, isSelected }: DiagnosticItemProps) => { {isSelected ? "› " : " "} {variant.icon} - {row.category}: {row.title} + {row.title} {row.siteCount > 1 ? ×{row.siteCount} : null} - - {" "} - {row.location} - ); }; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx index bb06f1d89..d3c9149fd 100644 --- a/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx @@ -1,15 +1,31 @@ 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; 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; } @@ -17,56 +33,127 @@ export interface DiagnosticListProps { const sumSites = (rows: ReadonlyArray): number => rows.reduce((total, row) => total + row.siteCount, 0); +const renderEntry = ( + entry: DiagnosticListEntry, + entryIndex: number, + selectedIndex: number, +): ReactNode => { + if (entry.kind === "header") { + return ( + + {entry.category} + + ); + } + return ( + + ); +}; + /** - * 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. + * 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: rows.length, + itemCount: entries.length, height: listHeight, + isSelectable: (index) => entries[index]?.kind === "item", }); useInput((input, key) => { if (input === "q" || key.escape) onExit(); }); - const visibleRows = rows.slice(visibleStart, visibleEnd); - const selected = rows[selectedIndex] ?? null; + 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 = ( + + {visibleEntries.map((entry, index) => + renderEntry(entry, visibleStart + index, selectedIndex), + )} + + ); + + const statusBar = ( + + + + ); + + if (isSplit) { + return ( + + + + {header} + {listColumn} + + + + + + {statusBar} + + ); + } return ( - - {visibleRows.map((row, index) => ( - - ))} - + {header} + {listColumn} {"─".repeat(width)} - - - + {statusBar} ); }; diff --git a/packages/react-doctor/src/cli/ink/components/project-select.tsx b/packages/react-doctor/src/cli/ink/components/project-select.tsx index 8c21cbdda..e2d8e70a1 100644 --- a/packages/react-doctor/src/cli/ink/components/project-select.tsx +++ b/packages/react-doctor/src/cli/ink/components/project-select.tsx @@ -1,10 +1,12 @@ import path from "node:path"; -import { Box, Text, useApp, useInput } from "ink"; -import { useState } from "react"; +import figures from "figures"; +import { Box, Text, useInput } from "ink"; +import { useMemo, useState } from "react"; +import type { ReactNode } 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"; +import { fuzzyMatch } from "../lib/fuzzy-match.js"; export interface ProjectSelectProps { readonly packages: ReadonlyArray; @@ -13,95 +15,257 @@ export interface ProjectSelectProps { readonly onSubmit: (directories: string[]) => void; } -const HEADER_ROWS = 2; -const FOOTER_ROWS = 2; -const MIN_LIST_ROWS = 3; +type SelectMode = "list" | "search"; + +interface ScoredPackage { + readonly workspacePackage: WorkspacePackage; + readonly matchedIndices: ReadonlyArray; +} + +// The header + the keymap hint always show; the filter/search line is only +// reserved when present. Subtracted from the terminal height so a long workspace +// scrolls instead of overflowing. +const BASE_CHROME_ROWS = 2; +const MIN_LIST_ROWS = 1; + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +// Accept a run of printable chars so both per-keystroke typing and a paste land +// in the filter (control/escape sequences are caught by the key checks first). +const isPrintable = (input: string): boolean => + input.length > 0 && [...input].every((char) => char.charCodeAt(0) >= 32); + +// Render the project name with its fuzzy-matched chars lit up, so it's clear why +// a result survived the filter. +const renderName = ( + name: string, + matchedIndices: ReadonlyArray, + isSelected: boolean, +): ReactNode => { + if (matchedIndices.length === 0) { + return ( + + {name} + + ); + } + const matched = new Set(matchedIndices); + return ( + + {[...name].map((char, index) => + matched.has(index) ? ( + + {char} + + ) : ( + char + ), + )} + + ); +}; /** - * 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). + * Interactive multiselect for a monorepo's projects — a compact flat list that + * navigates by default and only filters when you opt in. `/` enters search + * (type to fuzzy-filter, matched chars highlighted; `Enter` keeps the filter, + * `Esc` clears it). In the list, `space` toggles a row, `a` toggles all, `Enter` + * 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) => { - const { rows: terminalRows, columns } = useStdoutDimensions(); - const { exit } = useApp(); + const { rows: terminalRows } = useStdoutDimensions(); 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>( - () => new Set(packages.map((_, index) => index)), + + const [mode, setMode] = useState("list"); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [offset, setOffset] = useState(0); + const [checked, setChecked] = useState>(() => new Set()); + + const matches = useMemo>(() => { + const scored = packages.flatMap((workspacePackage) => { + const result = fuzzyMatch(query, workspacePackage.name); + return result ? [{ workspacePackage, result }] : []; + }); + if (query.length > 0) scored.sort((a, b) => b.result.score - a.result.score); + return scored.map(({ workspacePackage, result }) => ({ + workspacePackage, + matchedIndices: result.matchedIndices, + })); + }, [packages, query]); + + const isSearching = mode === "search"; + const hasFilterLine = isSearching || query.length > 0; + const listHeight = Math.max( + MIN_LIST_ROWS, + Math.min( + Math.max(matches.length, 1), + terminalRows - BASE_CHROME_ROWS - (hasFilterLine ? 1 : 0), + ), ); - const listHeight = Math.max(MIN_LIST_ROWS, terminalRows - HEADER_ROWS - FOOTER_ROWS); - const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({ - itemCount: packages.length, - height: listHeight, - }); + const boundedSelected = matches.length === 0 ? 0 : clamp(selectedIndex, 0, matches.length - 1); + const current = matches[boundedSelected]?.workspacePackage; + + const setFilter = (next: string): void => { + setQuery(next); + setSelectedIndex(0); + setOffset(0); + }; + + const move = (delta: number): void => { + if (matches.length === 0) return; + const next = clamp(boundedSelected + delta, 0, matches.length - 1); + setSelectedIndex(next); + setOffset((current) => { + if (next < current) return next; + if (next >= current + listHeight) return next - listHeight + 1; + return current; + }); + }; + + const toggleChecked = (directory: string): void => { + setChecked((current) => { + const next = new Set(current); + if (next.has(directory)) next.delete(directory); + else next.add(directory); + return next; + }); + }; + + // The caller (promptProjectSelection) clears the picker's frame and unmounts + // the Ink instance; doing it there — while the instance is still mounted — + // is what lets `clear()` actually erase the footer/list from scrollback. + const submit = (directories: ReadonlyArray): void => { + onSubmit([...directories]); + }; + + const scanSelection = (): void => { + if (checked.size > 0) { + return submit( + packages.filter((pkg) => checked.has(pkg.directory)).map((pkg) => pkg.directory), + ); + } + if (current) submit([current.directory]); + }; useInput((input, key) => { + if (isSearching) { + // Search mode: typing filters; Enter keeps the filter, Esc clears it, + // both return to the list. Arrows still move so you can aim mid-search. + if (key.return) return setMode("list"); + if (key.escape) { + setFilter(""); + return setMode("list"); + } + if (key.downArrow || (key.ctrl && input === "n")) return move(1); + if (key.upArrow || (key.ctrl && input === "p")) return move(-1); + if (key.backspace || key.delete) return setFilter(query.slice(0, -1)); + if (isPrintable(input) && !key.ctrl && !key.meta) setFilter(query + input); + return; + } + + if (input === "/") return setMode("search"); if (input === " ") { - setChecked((current) => { - const next = new Set(current); - if (next.has(selectedIndex)) next.delete(selectedIndex); - else next.add(selectedIndex); - return next; - }); + if (current) toggleChecked(current.directory); return; } if (input === "a") { setChecked((current) => - current.size === packages.length ? new Set() : new Set(packages.map((_, index) => index)), + matches.every((match) => current.has(match.workspacePackage.directory)) + ? new Set() + : new Set(matches.map((match) => match.workspacePackage.directory)), ); 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)); + if (input === "q") return submit([]); + // Esc backs out a level: clear the filter, then the selection, then cancel. + if (key.escape) { + if (query.length > 0) return setFilter(""); + if (checked.size > 0) return setChecked(new Set()); + return submit([]); } + if (key.return) return scanSelection(); + if (key.downArrow || input === "j") return move(1); + if (key.upArrow || input === "k") return move(-1); + if (key.pageDown) return move(listHeight); + if (key.pageUp) return move(-listHeight); }); - const width = Math.max(24, columns - 2); - const visiblePackages = packages.slice(visibleStart, visibleEnd); + const maxOffset = Math.max(0, matches.length - listHeight); + const visibleStart = Math.min(offset, maxOffset); + const visibleMatches = matches.slice(visibleStart, visibleStart + listHeight); + const longestNameLength = Math.max( + 0, + ...packages.map((workspacePackage) => workspacePackage.name.length), + ); + + // The footer's Enter clause states exactly what will run, so the empty-set + // fallback (scan the highlighted row) is never a surprise. + const enterAction = + checked.size === 0 + ? current + ? `scan ${current.name}` + : "scan" + : checked.size === packages.length + ? `scan all · ${checked.size}` + : `scan ${checked.size} selected`; return ( - - Select projects + + Select projects to scan - {" "} - {checked.size}/{packages.length} selected + {" "} + {checked.size}/{packages.length} - - {visiblePackages.map((workspacePackage, index) => { - const packageIndex = visibleStart + index; - const isSelected = packageIndex === selectedIndex; - const isChecked = checked.has(packageIndex); - return ( - - {isSelected ? "› " : " "} - {isChecked ? "◉ " : "◯ "} - {workspacePackage.name} - - {" "} - {path.relative(rootDirectory, workspacePackage.directory) || "."} + {isSearching ? ( + + {"/ "} + {query.length > 0 ? {query} : null} + + + ) : query.length > 0 ? ( + + {`filter: ${query}`} + + ) : null} + + {matches.length === 0 ? ( + No matching projects + ) : ( + visibleMatches.map((match, index) => { + const matchIndex = visibleStart + index; + const isSelected = matchIndex === boundedSelected; + const isChecked = checked.has(match.workspacePackage.directory); + return ( + + + {isSelected ? `${figures.pointer} ` : " "} + + + {isChecked ? `${figures.radioOn} ` : `${figures.radioOff} `} + + {renderName(match.workspacePackage.name, match.matchedIndices, isSelected)} + + {" ".repeat(longestNameLength - match.workspacePackage.name.length + 2)} + {path.relative(rootDirectory, match.workspacePackage.directory) || "."} + - - ); - })} + ); + }) + )} - {"─".repeat(width)} + {isSearching + ? "type to filter · enter confirm · esc clear" + : "space select · a all · / search · "} + {isSearching ? null : enter} + {isSearching ? null : ` ${enterAction} · q cancel`} - ↑↓ move · space toggle · a all · enter scan · q cancel ); }; diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index f7dcd9223..c899bf2ad 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -3,43 +3,60 @@ import { useMemo } from "react"; import type { ScanReport } from "../scan-store.js"; import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js"; import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; -import { buildCategoryTallies } from "../lib/category-tallies.js"; import { buildDiagnosticRows } from "../lib/diagnostic-rows.js"; -import { CategoryBreakdown } from "./category-breakdown.js"; import { DiagnosticList } from "./diagnostic-list.js"; import { ScoreHeader } from "./score-header.js"; export interface ReportProps { readonly report: ScanReport; - /** q / Esc handler. In a drill-in (monorepo) this pops back to the summary. */ + /** q / Esc handler that exits the app. */ readonly onExit: () => void; + /** When set (monorepo flat view), shows a "· N projects" span in the status bar. */ + readonly projectCount?: number; /** Hint shown in the empty-state footer (e.g. "Esc back · q quit"). */ readonly exitHint?: string; } -// Score header (face box, 4 lines + trailing blank + the "you could improve" -// line), the detail preview (headline + message + fix + location + a ~7-line -// code frame), the divider, and the status bar — reserved off the terminal -// height so the list gets the rest. Generous so the code frame never clips. +// Rows the score header eats (face box, 4 lines + trailing blank + the "you +// could improve" line), the stacked detail preview (headline + message + fix + +// location + a bordered ~7-line code frame), the divider, and the status bar — +// reserved off the terminal height so the list gets the rest. const HEADER_ROWS = 6; -const DETAIL_ROWS = 13; +const DETAIL_ROWS = 15; const STATUS_ROWS = 2; const DIVIDER_ROWS = 1; -const CHROME_ROWS = HEADER_ROWS + DETAIL_ROWS + STATUS_ROWS + DIVIDER_ROWS + 1; +const LIST_MARGIN_ROWS = 1; +// Stacked (narrow): header, list, divider, detail, and status all stack. +const STACKED_CHROME_ROWS = + HEADER_ROWS + LIST_MARGIN_ROWS + DETAIL_ROWS + DIVIDER_ROWS + STATUS_ROWS; +// Split (wide): the header sits atop the list in the left column and the detail +// fills the right column beside both, so only the header, the list margin, and +// the status bar are reserved off the column height. +const SPLIT_CHROME_ROWS = HEADER_ROWS + LIST_MARGIN_ROWS + STATUS_ROWS; const MIN_LIST_ROWS = 3; const MIN_WIDTH = 24; +// Below either of these the side-by-side layout is too cramped (the list and +// the detail's code frame fight for width / height), so fall back to stacked. +const WIDE_LAYOUT_MIN_COLUMNS = 120; +const WIDE_LAYOUT_MIN_ROWS = 22; +// Share of the width the detail column gets in the split layout (the code frame +// reads better with the larger share); the list takes the rest minus a gutter. +const DETAIL_WIDTH_FRACTION = 0.6; +const COLUMN_GUTTER_WIDTH = 3; +const MIN_COLUMN_WIDTH = 20; -/** Full interactive report: score header above the scrollable diagnostics list. */ -export const Report = ({ report, onExit, exitHint = "q quit" }: ReportProps) => { +/** + * Full interactive report: the score header above the scrollable, category- + * grouped diagnostics list. On a wide terminal the header sits atop the list in + * the left column and the detail preview fills the right column beside them; on + * a narrow one everything stacks. + */ +export const Report = ({ report, onExit, projectCount, exitHint = "q quit" }: ReportProps) => { const { rows: terminalRows, columns } = useStdoutDimensions(); const diagnosticRows = useMemo( () => buildDiagnosticRows(report.diagnostics, report.score), [report.diagnostics, report.score], ); - const categoryRowCount = useMemo( - () => buildCategoryTallies(report.diagnostics).length, - [report.diagnostics], - ); useExitOnCtrlC(); useInput((input, key) => { @@ -47,21 +64,32 @@ export const Report = ({ report, onExit, exitHint = "q quit" }: ReportProps) => }); const width = Math.max(MIN_WIDTH, columns - 2); - // The category breakdown sits between the header and the list; reserve its - // rows (plus a one-line margin) so the list viewport doesn't overflow. - const breakdownRows = categoryRowCount > 0 ? categoryRowCount + 1 : 0; - const listHeight = Math.max(MIN_LIST_ROWS, terminalRows - CHROME_ROWS - breakdownRows); + const isWide = columns >= WIDE_LAYOUT_MIN_COLUMNS && terminalRows >= WIDE_LAYOUT_MIN_ROWS; + const listHeight = Math.max( + MIN_LIST_ROWS, + terminalRows - (isWide ? SPLIT_CHROME_ROWS : STACKED_CHROME_ROWS), + ); + const detailColumnWidth = Math.max(MIN_COLUMN_WIDTH, Math.floor(width * DETAIL_WIDTH_FRACTION)); + const listColumnWidth = Math.max( + MIN_COLUMN_WIDTH, + width - detailColumnWidth - COLUMN_GUTTER_WIDTH, + ); + + const scoreHeader = ( + + ); if (diagnosticRows.length === 0) { return ( - + {scoreHeader} ✔ No issues found. Nice work. @@ -71,27 +99,18 @@ export const Report = ({ report, onExit, exitHint = "q quit" }: ReportProps) => } return ( - - - - - - - - - + ); }; diff --git a/packages/react-doctor/src/cli/ink/components/scanning.tsx b/packages/react-doctor/src/cli/ink/components/scanning.tsx index eed43104a..bb57cfb57 100644 --- a/packages/react-doctor/src/cli/ink/components/scanning.tsx +++ b/packages/react-doctor/src/cli/ink/components/scanning.tsx @@ -15,27 +15,29 @@ export const Scanning = ({ progressText, liveCount, recent }: ScanningProps) => - {" "} - - {progressText ?? "Scanning…"} - - {" "} - {liveCount} found + + {progressText ?? "Scanning…"} + {liveCount > 0 ? ( + + {" · "} + {liveCount} found + + ) : null} {recent.map((diagnostic, index) => { const variant = severityVariant(diagnostic.severity === "error" ? "error" : "warning"); const location = diagnostic.line > 0 ? `${diagnostic.filePath}:${diagnostic.line}` : diagnostic.filePath; return ( - + {" "} - {variant.icon} {diagnostic.title ?? `${diagnostic.plugin}/${diagnostic.rule}`}{" "} - {location} + {variant.icon} + {diagnostic.title ?? `${diagnostic.plugin}/${diagnostic.rule}`} + + {" "} + {location} + ); })} diff --git a/packages/react-doctor/src/cli/ink/components/score-header.tsx b/packages/react-doctor/src/cli/ink/components/score-header.tsx index 184250cfc..1e8d77617 100644 --- a/packages/react-doctor/src/cli/ink/components/score-header.tsx +++ b/packages/react-doctor/src/cli/ink/components/score-header.tsx @@ -1,4 +1,5 @@ -import { Box, Text } from "ink"; +import { Box, Text, useStdout } from "ink"; +import { useMemo } from "react"; import { PERFECT_SCORE, SCORE_BAR_WIDTH_CHARS, @@ -7,6 +8,8 @@ import { TOP_ERRORS_DISPLAY_COUNT, } from "@react-doctor/core"; import type { ScoreResult } from "@react-doctor/core"; +import { canAnimateOnboarding } from "../../utils/onboarding-pacing.js"; +import { useAnimatedScore } from "../hooks/use-animated-score.js"; import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; import { scoreColorName } from "../lib/score-color.js"; @@ -17,6 +20,8 @@ export interface ScoreHeaderProps { readonly issueCount: number; /** Shown when `score` is null (e.g. --no-score / API unreachable). */ readonly noScoreMessage?: string; + /** Available width to size the bar against; defaults to the terminal width. */ + readonly width?: number; } // Columns the face box + its gaps eat before the bar starts (2-space indent + @@ -39,8 +44,17 @@ export const ScoreHeader = ({ projectName, issueCount, noScoreMessage, + width, }: ScoreHeaderProps) => { const { columns } = useStdoutDimensions(); + const availableWidth = width ?? columns; + const { stdout } = useStdout(); + const animate = useMemo(() => canAnimateOnboarding(stdout ?? undefined), [stdout]); + const { displayScore, displayProjectedScore } = useAnimatedScore({ + score: score?.score ?? 0, + projectedScore, + animate: animate && score !== null, + }); if (!score) { return ( @@ -56,14 +70,14 @@ export const ScoreHeader = ({ const color = scoreColorName(score.score); const barWidth = Math.max( 10, - Math.min(SCORE_BAR_WIDTH_CHARS, columns - FACE_COLUMN_OFFSET - RIGHT_EDGE_SAFETY), + Math.min(SCORE_BAR_WIDTH_CHARS, availableWidth - FACE_COLUMN_OFFSET - RIGHT_EDGE_SAFETY), ); - const filled = Math.round((score.score / PERFECT_SCORE) * barWidth); + const filled = Math.round((displayScore / PERFECT_SCORE) * barWidth); // The "ghost gain" segment (▓): the points reclaimable by fixing the top // errors, dimmed in the current fill color — same total width as a plain bar. const projectedFill = - projectedScore != null - ? Math.min(barWidth, Math.round((projectedScore / PERFECT_SCORE) * barWidth)) + displayProjectedScore != null + ? Math.min(barWidth, Math.round((displayProjectedScore / PERFECT_SCORE) * barWidth)) : filled; const gain = Math.max(0, projectedFill - filled); const empty = Math.max(0, barWidth - filled - gain); @@ -81,7 +95,7 @@ export const ScoreHeader = ({ - {score.score} + {displayScore} / {PERFECT_SCORE} {score.label} diff --git a/packages/react-doctor/src/cli/ink/components/status-bar.tsx b/packages/react-doctor/src/cli/ink/components/status-bar.tsx index 3e3121b72..7406884cc 100644 --- a/packages/react-doctor/src/cli/ink/components/status-bar.tsx +++ b/packages/react-doctor/src/cli/ink/components/status-bar.tsx @@ -6,6 +6,8 @@ export interface StatusBarProps { readonly warningCount: number; readonly position: number; readonly groupCount: number; + /** When set (monorepo flat view), shows a "· N projects" span. */ + readonly projectCount?: number; readonly exitHint?: string; } @@ -16,6 +18,7 @@ export const StatusBar = ({ warningCount, position, groupCount, + projectCount, exitHint = "q quit", }: StatusBarProps) => ( @@ -28,6 +31,12 @@ export const StatusBar = ({ {warningCount} warnings + {projectCount !== undefined ? ( + + {" · "} + {projectCount} {projectCount === 1 ? "project" : "projects"} + + ) : null} {" "} {position}/{groupCount} · ↑↓ move · {exitHint} diff --git a/packages/react-doctor/src/cli/ink/components/summary.tsx b/packages/react-doctor/src/cli/ink/components/summary.tsx index a18e83a61..6cbc17af6 100644 --- a/packages/react-doctor/src/cli/ink/components/summary.tsx +++ b/packages/react-doctor/src/cli/ink/components/summary.tsx @@ -1,133 +1,29 @@ -import { Box, Text, useInput } from "ink"; -import { useState } from "react"; -import type { MultiProjectSummary } from "../scan-store.js"; -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"; -import { scoreBandLabel } from "../../utils/score-band-label.js"; -import { scoreColorName } from "../lib/score-color.js"; -import { CategoryBreakdown } from "./category-breakdown.js"; +import type { MultiProjectSummary, ScanReport } from "../scan-store.js"; import { Report } from "./report.js"; -import { ScoreHeader } from "./score-header.js"; export interface SummaryProps { readonly summary: MultiProjectSummary; readonly onExit: () => void; } -// Aggregate header (face box + improve line), the combined category breakdown, -// a one-line margin, and the status row — reserved off the terminal height. -const HEADER_ROWS = 7; -const STATUS_ROWS = 2; -const MIN_LIST_ROWS = 3; - -const pluralize = (count: number, noun: string): string => - `${count} ${count === 1 ? noun : `${noun}s`}`; - /** - * The monorepo aggregate view: the worst project's score, the combined category - * breakdown, and a scrollable project list. Enter drills into a project's full - * report; Esc there pops back here. + * The monorepo view: one flat, scrollable list of every project's findings — + * no per-folder drill-in. Each row's location is already qualified with its + * project folder (rewritten relative to the monorepo root in `runScanApp`), and + * the shared root resolves every code frame, so this is just the single-project + * `Report` fed the combined diagnostics plus the aggregate (worst) score. */ export const Summary = ({ summary, onExit }: SummaryProps) => { - const { rows: terminalRows, columns } = useStdoutDimensions(); - const [drilledIndex, setDrilledIndex] = useState(null); - useExitOnCtrlC(); - - const drilled = drilledIndex === null ? null : (summary.projects[drilledIndex] ?? null); - - const categoryRowCount = (() => { - const categories = new Set( - summary.combinedDiagnostics.map((diagnostic) => diagnostic.category), - ); - return categories.size; - })(); - const breakdownRows = categoryRowCount > 0 ? categoryRowCount + 1 : 0; - const listHeight = Math.max( - MIN_LIST_ROWS, - terminalRows - HEADER_ROWS - STATUS_ROWS - breakdownRows, - ); - - const { selectedIndex, visibleStart, visibleEnd } = useScrollViewport({ - itemCount: summary.projects.length, - height: listHeight, - isActive: drilled === null, - onActivate: (index) => setDrilledIndex(index), - }); - - // Only quits at the top level; in a drill-in the child `Report` owns Esc/q. - useInput( - (input) => { - if (input === "q") onExit(); - }, - { isActive: drilled === null }, - ); - - if (drilled !== null) { - return setDrilledIndex(null)} exitHint="esc back" />; - } - - const longestNameLength = Math.max( - 0, - ...summary.projects.map((project) => project.projectName.length), - ); - const width = Math.max(24, columns - 2); - const visibleProjects = summary.projects.slice(visibleStart, visibleEnd); - - return ( - - - - - - - {visibleProjects.map((project, index) => { - const isSelected = visibleStart + index === selectedIndex; - const errorCount = project.diagnostics.filter( - (diagnostic) => diagnostic.severity === "error", - ).length; - const warningCount = project.diagnostics.length - errorCount; - const score = project.score?.score ?? null; - return ( - - - {isSelected ? "› " : " "} - - - {project.projectName.padEnd(longestNameLength)} - - {score !== null ? ( - - {" "} - {String(score).padStart(3)} {scoreBandLabel(score)} - - ) : ( - {" no score"} - )} - {" "} - {errorCount > 0 ? {pluralize(errorCount, "error")} : null} - {errorCount > 0 && warningCount > 0 ? , : null} - {warningCount > 0 ? ( - {pluralize(warningCount, "warning")} - ) : null} - - ); - })} - - {"─".repeat(width)} - - {pluralize(summary.projects.length, "project")} - - {" "} - {selectedIndex + 1}/{summary.projects.length} · ↑↓ move · enter open · q quit - - - - ); + const report: ScanReport = { + diagnostics: summary.combinedDiagnostics, + score: summary.aggregateScore, + projectedScore: summary.projectedScore, + projectName: summary.projectName, + rootDirectory: summary.rootDirectory, + scannedFileCount: summary.scannedFileCount, + elapsedMilliseconds: summary.elapsedMilliseconds, + isOffline: summary.isOffline, + noScoreMessage: summary.noScoreMessage, + }; + return ; }; diff --git a/packages/react-doctor/src/cli/ink/hooks/use-animated-score.ts b/packages/react-doctor/src/cli/ink/hooks/use-animated-score.ts new file mode 100644 index 000000000..309baaa43 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/hooks/use-animated-score.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { + SCORE_HEADER_ANIMATION_FRAME_COUNT, + SCORE_HEADER_ANIMATION_FRAME_DELAY_MS, + SCORE_PROJECTION_FRAME_COUNT, + SCORE_PROJECTION_FRAME_DELAY_MS, +} from "../../utils/constants.js"; +import { easeOutCubic } from "../../utils/ease-out-cubic.js"; + +export interface UseAnimatedScoreOptions { + readonly score: number; + /** Score reachable by fixing the top errors (the bar's ghost gain), or null. */ + readonly projectedScore: number | null; + /** When false, returns the final values immediately (tests / non-TTY). */ + readonly animate: boolean; +} + +export interface AnimatedScore { + /** Counts up from 0 to `score`, eased. */ + readonly displayScore: number; + /** Grows from `score` to `projectedScore` once the count-up settles; null when no gain. */ + readonly displayProjectedScore: number | null; +} + +/** + * Drives the score header's reveal: the number counts up while the bar fills + * (ease-out cubic), then the projection "ghost gain" grows in — the Ink mirror + * of the static CLI's `printAnimatedScore` / `animateScoreProjection`. + */ +export const useAnimatedScore = ({ + score, + projectedScore, + animate, +}: UseAnimatedScoreOptions): AnimatedScore => { + const hasProjection = projectedScore !== null && projectedScore > score; + const [displayScore, setDisplayScore] = useState(animate ? 0 : score); + const [displayProjectedScore, setDisplayProjectedScore] = useState( + animate ? null : hasProjection ? projectedScore : null, + ); + + useEffect(() => { + if (!animate) { + setDisplayScore(score); + setDisplayProjectedScore(hasProjection ? projectedScore : null); + return; + } + + let timeoutId: ReturnType; + + // Phase 2: grow the projection ghost from the real score to its potential. + const runProjection = (): void => { + let frame = 1; + const tick = (): void => { + const progress = easeOutCubic(frame / SCORE_PROJECTION_FRAME_COUNT); + setDisplayProjectedScore(score + (projectedScore! - score) * progress); + if (frame < SCORE_PROJECTION_FRAME_COUNT) { + frame += 1; + timeoutId = setTimeout(tick, SCORE_PROJECTION_FRAME_DELAY_MS); + } else { + setDisplayProjectedScore(projectedScore); + } + }; + tick(); + }; + + // Phase 1: count the score up while the bar fills. + let frame = 0; + const tick = (): void => { + const progress = easeOutCubic(frame / SCORE_HEADER_ANIMATION_FRAME_COUNT); + setDisplayScore(Math.round(score * progress)); + if (frame < SCORE_HEADER_ANIMATION_FRAME_COUNT) { + frame += 1; + timeoutId = setTimeout(tick, SCORE_HEADER_ANIMATION_FRAME_DELAY_MS); + return; + } + setDisplayScore(score); + if (hasProjection) runProjection(); + }; + tick(); + + return () => clearTimeout(timeoutId); + }, [animate, score, projectedScore, hasProjection]); + + return { displayScore, displayProjectedScore }; +}; diff --git a/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts b/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts index fd4199c35..55fb1e980 100644 --- a/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts +++ b/packages/react-doctor/src/cli/ink/hooks/use-scroll-viewport.ts @@ -15,6 +15,13 @@ export interface UseScrollViewportOptions { readonly isActive?: boolean; /** Fired on Enter for the selected index. */ readonly onActivate?: (index: number) => void; + /** + * When set, navigation only ever lands on indices for which this returns true + * — used to skip non-selectable rows like category headers in a grouped list. + * The viewport window still counts every row (headers included) so the height + * math stays in terminal-line units. + */ + readonly isSelectable?: (index: number) => boolean; } const HALF = 2; @@ -26,15 +33,40 @@ const HALF = 2; * the cmdk-style "logic, not chrome" core the `` builds on. */ export const useScrollViewport = (options: UseScrollViewportOptions): ScrollViewport => { - const { itemCount, height, isActive = true, onActivate } = options; - const [selectedIndex, setSelectedIndex] = useState(0); + const { itemCount, height, isActive = true, onActivate, isSelectable } = options; + + const canSelect = (index: number): boolean => + index >= 0 && index < itemCount && (isSelectable ? isSelectable(index) : true); + + // First selectable index from `start`, scanning in `step` direction (±1). + const seekSelectable = (start: number, step: number): number => { + for (let index = start; index >= 0 && index < itemCount; index += step) { + if (canSelect(index)) return index; + } + return -1; + }; + + // Nearest selectable index to `target`, preferring `step` then reversing — so + // moving down onto a header lands on the next item, and there's no way to + // strand the selection on a non-selectable row. + const nearestSelectable = (target: number, step: number): number => { + const ahead = seekSelectable(target, step); + if (ahead !== -1) return ahead; + const behind = seekSelectable(target, -step); + return behind === -1 ? target : behind; + }; + + const [selectedIndex, setSelectedIndex] = useState(() => { + const first = seekSelectable(0, 1); + return first === -1 ? 0 : first; + }); const [offset, setOffset] = useState(0); const awaitingSecondG = useRef(false); const clampIndex = (index: number): number => Math.max(0, Math.min(itemCount - 1, index)); - const moveTo = (rawIndex: number): void => { - const next = clampIndex(rawIndex); + const moveTo = (rawIndex: number, step: number): void => { + const next = nearestSelectable(clampIndex(rawIndex), step); setSelectedIndex(next); setOffset((current) => { if (next < current) return next; @@ -49,16 +81,16 @@ export const useScrollViewport = (options: UseScrollViewportOptions): ScrollView const isSecondG = awaitingSecondG.current && input === "g"; if (input !== "g") awaitingSecondG.current = false; - if (key.downArrow || input === "j") return moveTo(selectedIndex + 1); - if (key.upArrow || input === "k") return moveTo(selectedIndex - 1); - if (key.pageDown) return moveTo(selectedIndex + height); - if (key.pageUp) return moveTo(selectedIndex - height); - if (key.ctrl && input === "d") return moveTo(selectedIndex + Math.floor(height / HALF)); - if (key.ctrl && input === "u") return moveTo(selectedIndex - Math.floor(height / HALF)); - if (input === "G") return moveTo(itemCount - 1); + if (key.downArrow || input === "j") return moveTo(selectedIndex + 1, 1); + if (key.upArrow || input === "k") return moveTo(selectedIndex - 1, -1); + if (key.pageDown) return moveTo(selectedIndex + height, 1); + if (key.pageUp) return moveTo(selectedIndex - height, -1); + if (key.ctrl && input === "d") return moveTo(selectedIndex + Math.floor(height / HALF), 1); + if (key.ctrl && input === "u") return moveTo(selectedIndex - Math.floor(height / HALF), -1); + if (input === "G") return moveTo(itemCount - 1, -1); if (isSecondG) { awaitingSecondG.current = false; - return moveTo(0); + return moveTo(0, 1); } if (input === "g") { awaitingSecondG.current = true; @@ -69,11 +101,16 @@ export const useScrollViewport = (options: UseScrollViewportOptions): ScrollView { isActive }, ); - // Re-clamp every render so a shrinking list can't strand the window past the end. + // Re-clamp every render so a shrinking list can't strand the window past the + // end, and re-resolve the selection so a changed list can't leave it on a + // header or past the new end. const maxOffset = Math.max(0, itemCount - height); const visibleStart = Math.min(offset, maxOffset); + const resolvedSelected = canSelect(selectedIndex) + ? selectedIndex + : nearestSelectable(clampIndex(selectedIndex), 1); return { - selectedIndex: clampIndex(selectedIndex), + selectedIndex: resolvedSelected, visibleStart, visibleEnd: Math.min(itemCount, visibleStart + height), }; diff --git a/packages/react-doctor/src/cli/ink/lib/category-tallies.ts b/packages/react-doctor/src/cli/ink/lib/category-tallies.ts deleted file mode 100644 index 69d0f708b..000000000 --- a/packages/react-doctor/src/cli/ink/lib/category-tallies.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DIAGNOSTIC_CATEGORY_BUCKETS } from "@react-doctor/core"; -import type { Diagnostic } from "@react-doctor/core"; - -/** One category row in the breakdown: its error / warning counts. */ -export interface CategoryTally { - readonly category: string; - readonly errorCount: number; - readonly warningCount: number; -} - -// Fixed display order (Security first), mirroring the CLI's category breakdown -// so the reader scans to a category by position, not by the day's weighting. -const CATEGORY_RANK = new Map( - DIAGNOSTIC_CATEGORY_BUCKETS.map((category, index) => [category, index]), -); - -const rankOf = (category: string): number => CATEGORY_RANK.get(category) ?? Number.MAX_SAFE_INTEGER; - -/** - * Groups diagnostics into per-category error / warning tallies, ordered by the - * canonical category display rank (unknown categories sort last, alphabetically). - */ -export const buildCategoryTallies = (diagnostics: ReadonlyArray): CategoryTally[] => { - const tallyByCategory = new Map(); - for (const diagnostic of diagnostics) { - const tally = tallyByCategory.get(diagnostic.category) ?? { errorCount: 0, warningCount: 0 }; - if (diagnostic.severity === "error") tally.errorCount += 1; - else tally.warningCount += 1; - tallyByCategory.set(diagnostic.category, tally); - } - - return [...tallyByCategory.entries()] - .map(([category, counts]) => ({ category, ...counts })) - .sort((tallyA, tallyB) => { - const rankDelta = rankOf(tallyA.category) - rankOf(tallyB.category); - return rankDelta !== 0 ? rankDelta : tallyA.category.localeCompare(tallyB.category); - }); -}; diff --git a/packages/react-doctor/src/cli/ink/lib/diagnostic-list-entries.ts b/packages/react-doctor/src/cli/ink/lib/diagnostic-list-entries.ts new file mode 100644 index 000000000..09d50f440 --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/diagnostic-list-entries.ts @@ -0,0 +1,63 @@ +import { DIAGNOSTIC_CATEGORY_BUCKETS } from "@react-doctor/core"; +import type { DiagnosticRow } from "./diagnostic-rows.js"; + +/** A category title line in the grouped list (not selectable). */ +export interface DiagnosticHeaderEntry { + readonly kind: "header"; + readonly category: string; + readonly errorCount: number; + readonly warningCount: number; +} + +/** A selectable rule-group row, rendered indented under its category header. */ +export interface DiagnosticItemEntry { + readonly kind: "item"; + readonly row: DiagnosticRow; +} + +export type DiagnosticListEntry = DiagnosticHeaderEntry | DiagnosticItemEntry; + +// Fixed category display order (Security first), mirroring the CLI's grouped +// diagnostics and the breakdown strip so the reader scans to a category by +// position. Unknown categories (legacy / adopted) sort last, alphabetically. +const CATEGORY_RANK = new Map( + DIAGNOSTIC_CATEGORY_BUCKETS.map((category, index) => [category, index]), +); + +const rankOf = (category: string): number => CATEGORY_RANK.get(category) ?? Number.MAX_SAFE_INTEGER; + +/** + * Flattens the priority-sorted rule rows into a category-grouped display list: + * a header line per category (ordered by display rank) followed by its rows, + * which keep their incoming priority order within the group. The header carries + * the row's incoming order so the per-category rule ranking is preserved. + */ +export const buildDiagnosticListEntries = ( + rows: ReadonlyArray, +): DiagnosticListEntry[] => { + const rowsByCategory = new Map(); + for (const row of rows) { + const categoryRows = rowsByCategory.get(row.category) ?? []; + categoryRows.push(row); + rowsByCategory.set(row.category, categoryRows); + } + + const orderedCategories = [...rowsByCategory.keys()].sort((categoryA, categoryB) => { + const rankDelta = rankOf(categoryA) - rankOf(categoryB); + return rankDelta !== 0 ? rankDelta : categoryA.localeCompare(categoryB); + }); + + const entries: DiagnosticListEntry[] = []; + for (const category of orderedCategories) { + const categoryRows = rowsByCategory.get(category) ?? []; + let errorCount = 0; + let warningCount = 0; + for (const row of categoryRows) { + if (row.severity === "error") errorCount += row.siteCount; + else warningCount += row.siteCount; + } + entries.push({ kind: "header", category, errorCount, warningCount }); + for (const row of categoryRows) entries.push({ kind: "item", row }); + } + return entries; +}; diff --git a/packages/react-doctor/src/cli/ink/lib/fuzzy-match.ts b/packages/react-doctor/src/cli/ink/lib/fuzzy-match.ts new file mode 100644 index 000000000..355cd2b8b --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/fuzzy-match.ts @@ -0,0 +1,52 @@ +export interface FuzzyMatchResult { + /** Higher is a better match; used to rank results. */ + readonly score: number; + /** Indices in the target that matched, for highlighting. */ + readonly matchedIndices: ReadonlyArray; +} + +// Scoring weights, tuned so contiguous runs and word-start hits (the chars a +// human aims for) win, and earlier matches edge out later ones. +const CONSECUTIVE_BONUS = 5; +const WORD_BOUNDARY_BONUS = 10; +const LEADING_PENALTY = 1; + +const isWordBoundaryBefore = (target: string, index: number): boolean => { + if (index === 0) return true; + const previous = target[index - 1]; + return previous === "-" || previous === "_" || previous === "/" || previous === " "; +}; + +/** + * A small subsequence fuzzy matcher (fzf-style): every query char must appear in + * order in the target, scored so contiguous and word-boundary hits rank highest. + * Returns `null` when the query is not a subsequence. An empty query matches + * everything with a neutral score, preserving the source order. + */ +export const fuzzyMatch = (query: string, target: string): FuzzyMatchResult | null => { + if (query.length === 0) return { score: 0, matchedIndices: [] }; + + const lowerQuery = query.toLowerCase(); + const lowerTarget = target.toLowerCase(); + const matchedIndices: number[] = []; + let score = 0; + let queryIndex = 0; + let previousMatchIndex = -2; + + for ( + let targetIndex = 0; + targetIndex < lowerTarget.length && queryIndex < lowerQuery.length; + targetIndex++ + ) { + if (lowerTarget[targetIndex] !== lowerQuery[queryIndex]) continue; + matchedIndices.push(targetIndex); + if (targetIndex === previousMatchIndex + 1) score += CONSECUTIVE_BONUS; + if (isWordBoundaryBefore(target, targetIndex)) score += WORD_BOUNDARY_BONUS; + previousMatchIndex = targetIndex; + queryIndex++; + } + + if (queryIndex < lowerQuery.length) return null; + score -= matchedIndices[0] * LEADING_PENALTY; + return { score, matchedIndices }; +}; diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index d6ca90ffb..da93c31a3 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -50,6 +50,25 @@ export interface RunScanAppResult { const countBySeverity = (diagnostics: ReadonlyArray, severity: string): number => diagnostics.filter((diagnostic) => diagnostic.severity === severity).length; +// Rewrites a project's diagnostics so their `filePath` is relative to the +// monorepo root rather than the project root. This lets the flat summary render +// every project's findings in one list — the path itself shows the folder, and +// the shared monorepo root resolves every code frame. A no-op for the root +// project (empty prefix) and for already-absolute paths. +const qualifyDiagnosticPaths = ( + diagnostics: ReadonlyArray, + rootDirectory: string, + projectDirectory: string, +): Diagnostic[] => { + const prefix = path.relative(rootDirectory, projectDirectory); + if (prefix === "" || prefix.startsWith("..")) return [...diagnostics]; + return diagnostics.map((diagnostic) => + path.isAbsolute(diagnostic.filePath) + ? diagnostic + : { ...diagnostic, filePath: path.join(prefix, diagnostic.filePath) }, + ); +}; + // The share URL is suppressed for --no-score, `share: false` in config, and in // CI, mirroring the CLI's `shouldShowShareLink` gate exactly (CI only — it does // not additionally gate on coding-agent environments). @@ -91,6 +110,9 @@ const promptProjectSelection = ( packages={packages} rootDirectory={rootDirectory} onSubmit={(directories) => { + // Wipe the picker's last frame so its footer/list doesn't linger in + // scrollback above the scan view once a selection is made. + instance.clear(); instance.unmount(); resolve(directories); }} @@ -240,7 +262,10 @@ const runMultiProjectScan = async ( async (projectDirectory) => { const result = await inspect(projectDirectory, { ...input.options, - suppressRendering: true, + // Stream each project's diagnostics into the shared store so the live + // scan view shows the error feed (uiStore implies suppressRendering); + // `concurrentScan` keeps per-project progress off the shared counter. + uiStore: store, concurrentScan: true, }); finishedCount += 1; @@ -261,7 +286,9 @@ const runMultiProjectScan = async ( noScoreMessage, }), ); - const combinedDiagnostics = projects.flatMap((project) => [...project.diagnostics]); + const combinedDiagnostics = projects.flatMap((project) => + qualifyDiagnosticPaths(project.diagnostics, rootDirectory, project.rootDirectory), + ); const worst = findLowestScored(projects); const projectedScore = worst ? await computeProjectedScore(combinedDiagnostics, [...worst.diagnostics], worst.score) @@ -279,6 +306,7 @@ const runMultiProjectScan = async ( scannedFileCount, elapsedMilliseconds, projectName: path.basename(rootDirectory), + rootDirectory, isOffline, noScoreMessage, }; diff --git a/packages/react-doctor/src/cli/ink/scan-app.tsx b/packages/react-doctor/src/cli/ink/scan-app.tsx index 595f81172..b771a744b 100644 --- a/packages/react-doctor/src/cli/ink/scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/scan-app.tsx @@ -9,7 +9,7 @@ export interface ScanAppProps { readonly store: ScanStore; } -const RECENT_LIVE_COUNT = 8; +const RECENT_LIVE_COUNT = 5; /** Root of the interactive scan UI: routes the store phase to a view. */ export const ScanApp = ({ store }: ScanAppProps) => { diff --git a/packages/react-doctor/src/cli/ink/scan-store.ts b/packages/react-doctor/src/cli/ink/scan-store.ts index 9e1c7368e..0eab25ce8 100644 --- a/packages/react-doctor/src/cli/ink/scan-store.ts +++ b/packages/react-doctor/src/cli/ink/scan-store.ts @@ -41,6 +41,8 @@ export interface MultiProjectSummary { readonly scannedFileCount: number; readonly elapsedMilliseconds: number; readonly projectName: string; + /** Monorepo root the combined diagnostics' paths are resolved against. */ + readonly rootDirectory: string; readonly isOffline: boolean; readonly noScoreMessage: string; } diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index e071c3a69..7579f2fd9 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -568,7 +568,13 @@ const runInspectWithRuntime = async ( shouldShowProgressSpinners, oxlintConcurrency: options.concurrency, reporterLayer: options.uiStore ? reporterLayerForStore(options.uiStore) : undefined, - progressLayer: options.uiStore ? progressLayerForStore(options.uiStore) : undefined, + // In a concurrent batch the parent loop owns the shared progress line (the + // "x/N projects" counter), so per-project progress stays a no-op while the + // live diagnostic stream is still forwarded. A lone scan drives both. + progressLayer: + options.uiStore && !options.concurrentScan + ? progressLayerForStore(options.uiStore) + : undefined, }); const program = runInspectEffect( diff --git a/packages/react-doctor/tests/ink/diagnostic-list-entries.test.ts b/packages/react-doctor/tests/ink/diagnostic-list-entries.test.ts new file mode 100644 index 000000000..ae9fd72e5 --- /dev/null +++ b/packages/react-doctor/tests/ink/diagnostic-list-entries.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { Diagnostic } from "@react-doctor/core"; +import { buildDiagnosticListEntries } from "../../src/cli/ink/lib/diagnostic-list-entries.js"; +import { buildDiagnosticRows } from "../../src/cli/ink/lib/diagnostic-rows.js"; + +const makeDiagnostic = (overrides: Partial): Diagnostic => ({ + filePath: "src/App.tsx", + plugin: "react-doctor", + rule: "rule", + severity: "warning", + message: "", + help: "", + line: 1, + column: 1, + category: "Bugs", + ...overrides, +}); + +describe("buildDiagnosticListEntries", () => { + it("groups rows under one header per category, ordered by display rank", () => { + const rows = buildDiagnosticRows( + [ + makeDiagnostic({ rule: "a", category: "Bugs" }), + makeDiagnostic({ rule: "b", category: "Security", severity: "error" }), + makeDiagnostic({ rule: "c", category: "Maintainability" }), + ], + null, + ); + + const entries = buildDiagnosticListEntries(rows); + const headers = entries.filter((entry) => entry.kind === "header"); + + // Security (rank 0) before Bugs (rank 1) before Maintainability. + expect(headers.map((header) => header.kind === "header" && header.category)).toEqual([ + "Security", + "Bugs", + "Maintainability", + ]); + // Every header is immediately followed by at least one item entry. + for (const [index, entry] of entries.entries()) { + if (entry.kind === "header") expect(entries[index + 1]?.kind).toBe("item"); + } + }); + + it("tallies each category header by site count and severity", () => { + const rows = buildDiagnosticRows( + [ + makeDiagnostic({ rule: "a", category: "Bugs", severity: "error", filePath: "x.tsx" }), + makeDiagnostic({ rule: "a", category: "Bugs", severity: "error", filePath: "y.tsx" }), + makeDiagnostic({ rule: "b", category: "Bugs", severity: "warning" }), + ], + null, + ); + + const [header] = buildDiagnosticListEntries(rows); + expect(header.kind === "header" && header.errorCount).toBe(2); + expect(header.kind === "header" && header.warningCount).toBe(1); + }); +}); diff --git a/packages/react-doctor/tests/ink/fuzzy-match.test.ts b/packages/react-doctor/tests/ink/fuzzy-match.test.ts new file mode 100644 index 000000000..4de1ed5c2 --- /dev/null +++ b/packages/react-doctor/tests/ink/fuzzy-match.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; +import { fuzzyMatch } from "../../src/cli/ink/lib/fuzzy-match.js"; + +describe("fuzzyMatch", () => { + it("matches everything with a neutral score for an empty query", () => { + expect(fuzzyMatch("", "anything")).toEqual({ score: 0, matchedIndices: [] }); + }); + + it("returns null when the query is not a subsequence", () => { + expect(fuzzyMatch("xyz", "react-doctor")).toBeNull(); + }); + + it("reports the matched indices in order", () => { + const result = fuzzyMatch("rd", "react-doctor"); + expect(result?.matchedIndices).toEqual([0, 6]); + }); + + it("ranks a contiguous match above a scattered one", () => { + const contiguous = fuzzyMatch("abc", "abcde"); + const scattered = fuzzyMatch("abc", "axbxc"); + expect(contiguous).not.toBeNull(); + expect(scattered).not.toBeNull(); + expect(contiguous!.score).toBeGreaterThan(scattered!.score); + }); + + it("is case-insensitive", () => { + expect(fuzzyMatch("WEB", "website")).not.toBeNull(); + }); +}); diff --git a/packages/react-doctor/tests/ink/project-select.test.tsx b/packages/react-doctor/tests/ink/project-select.test.tsx index 68d94639f..26ad93e44 100644 --- a/packages/react-doctor/tests/ink/project-select.test.tsx +++ b/packages/react-doctor/tests/ink/project-select.test.tsx @@ -11,20 +11,110 @@ const PACKAGES: WorkspacePackage[] = [ // ink-testing-library needs a tick for effects (useInput wiring) to flush. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); +const DOWN_ARROW = "\u001b[B"; +const ENTER = "\r"; +const ESC = "\u001b"; + describe("ProjectSelect", () => { - it("submits the selected directories when Enter is pressed", async () => { + it("scans only the highlighted project when Enter is pressed with nothing checked", async () => { + const onSubmit = vi.fn(); + const { stdin, unmount } = render( + , + ); + await flush(); + + stdin.write(ENTER); + await flush(); + + expect(onSubmit).toHaveBeenCalledWith(["/repo/apps/web"]); + unmount(); + }); + + it("scans the whole workspace after select-all", async () => { const onSubmit = vi.fn(); const { stdin, unmount } = render( , ); await flush(); - // Enter arrives as a carriage return that Ink normalizes to `key.return`. - stdin.write("\r"); + stdin.write("a"); + await flush(); + stdin.write(ENTER); await flush(); - expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith(["/repo/apps/web", "/repo/apps/docs"]); unmount(); }); + + it("scans the subset built with space", async () => { + const onSubmit = vi.fn(); + const { stdin, unmount } = render( + , + ); + await flush(); + + stdin.write(DOWN_ARROW); + await flush(); + stdin.write(" "); + await flush(); + stdin.write(ENTER); + await flush(); + + expect(onSubmit).toHaveBeenCalledWith(["/repo/apps/docs"]); + unmount(); + }); + + it("does not filter until search mode is entered with '/'", async () => { + const onSubmit = vi.fn(); + const { stdin, lastFrame, unmount } = render( + , + ); + await flush(); + + // Outside search mode, letters are commands — typing "doc" doesn't filter. + stdin.write("doc"); + await flush(); + expect(lastFrame()).toContain("web"); + expect(lastFrame()).toContain("docs"); + + // Enter search, filter to "doc", confirm, then scan the surviving match. + stdin.write("/"); + await flush(); + stdin.write("doc"); + await flush(); + expect(lastFrame()).toContain("docs"); + expect(lastFrame()).not.toContain("web"); + + stdin.write(ENTER); + await flush(); + stdin.write(ENTER); + await flush(); + + expect(onSubmit).toHaveBeenCalledWith(["/repo/apps/docs"]); + unmount(); + }); + + it("clears the selection on Esc before cancelling", async () => { + const onSubmit = vi.fn(); + const { stdin, lastFrame, unmount } = render( + , + ); + await flush(); + + stdin.write("a"); + await flush(); + expect(lastFrame()).toContain("2/2"); + + // First Esc clears the selection (no cancel yet)... + stdin.write(ESC); + await flush(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(lastFrame()).toContain("0/2"); + + // ...a second Esc cancels. + stdin.write(ESC); + await flush(); + expect(onSubmit).toHaveBeenCalledWith([]); + unmount(); + }); }); diff --git a/packages/react-doctor/tests/ink/scan-app.test.tsx b/packages/react-doctor/tests/ink/scan-app.test.tsx index 0185fe620..b1a664153 100644 --- a/packages/react-doctor/tests/ink/scan-app.test.tsx +++ b/packages/react-doctor/tests/ink/scan-app.test.tsx @@ -22,6 +22,15 @@ const SCORE: ScoreResult = { score: 72, label: "Fair" }; // ink-testing-library needs a tick for effects (useInput wiring) to flush. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); +// ink-testing-library hardcodes 100 columns (< the split threshold), so the +// report renders stacked by default. Widen the fake stdout and fire a resize so +// the report switches to the side-by-side layout for that test. +const widenTerminal = (stdout: { emit: (event: string) => void }): void => { + Object.defineProperty(stdout, "columns", { get: () => 140, configurable: true }); + Object.defineProperty(stdout, "rows", { get: () => 40, configurable: true }); + stdout.emit("resize"); +}; + describe("ScanApp", () => { it("renders the live scan view before a report settles", () => { const store = createScanStore(); @@ -37,10 +46,22 @@ describe("ScanApp", () => { it("renders the score header and the full sorted rule list once settled", () => { const store = createScanStore(); + // All in one category so the grouped list shows a single "Correctness" + // header with both rules under it (fits the small test viewport). const diagnostics = [ makeDiagnostic({ rule: "rules-of-hooks", severity: "error", category: "Correctness" }), - makeDiagnostic({ rule: "no-array-index-key", filePath: "src/Cart.tsx", line: 9 }), - makeDiagnostic({ rule: "no-array-index-key", filePath: "src/List.tsx", line: 4 }), + makeDiagnostic({ + rule: "no-array-index-key", + category: "Correctness", + filePath: "src/Cart.tsx", + line: 9, + }), + makeDiagnostic({ + rule: "no-array-index-key", + category: "Correctness", + filePath: "src/List.tsx", + line: 4, + }), ]; store.setReport({ diagnostics, @@ -59,7 +80,9 @@ describe("ScanApp", () => { expect(frame).toContain("72"); expect(frame).toContain("demo-app"); // No `title` on the test diagnostics → the row falls back to `plugin/rule`. - expect(frame).toContain("Correctness: react-doctor/rules-of-hooks"); + // The detail headline is the title alone; category + severity ride a dim tag. + expect(frame).toContain("react-doctor/rules-of-hooks"); + expect(frame).toContain("Correctness · error"); // The second rule groups its two sites into one row with a count badge. expect(frame).toContain("×2"); unmount(); @@ -110,10 +133,19 @@ describe("ScanApp", () => { unmount(); }); - it("renders the monorepo summary with aggregate score and project rows", () => { + it("renders a flat monorepo summary: aggregate score, combined list, folder-qualified paths", () => { const store = createScanStore(); + // Combined diagnostics carry folder-qualified paths (rewritten relative to + // the monorepo root in `runScanApp`) so the flat list shows each finding's + // project without a per-folder drill-in. const webReport = { - diagnostics: [makeDiagnostic({ rule: "rules-of-hooks", severity: "error" })], + diagnostics: [ + makeDiagnostic({ + rule: "rules-of-hooks", + severity: "error", + filePath: "apps/web/src/Profile.tsx", + }), + ], score: { score: 58, label: "Needs work" } as ScoreResult, projectedScore: null, projectName: "web", @@ -124,7 +156,13 @@ describe("ScanApp", () => { noScoreMessage: "Score unavailable.", }; const apiReport = { - diagnostics: [makeDiagnostic({ rule: "no-array-index-key", severity: "warning" })], + diagnostics: [ + makeDiagnostic({ + rule: "no-array-index-key", + severity: "warning", + filePath: "apps/api/src/Cart.tsx", + }), + ], score: { score: 91, label: "Great" } as ScoreResult, projectedScore: null, projectName: "api", @@ -142,6 +180,7 @@ describe("ScanApp", () => { scannedFileCount: 10, elapsedMilliseconds: 12, projectName: "repo", + rootDirectory: "/tmp/repo", isOffline: true, noScoreMessage: "Score unavailable.", }); @@ -150,8 +189,12 @@ describe("ScanApp", () => { const frame = lastFrame() ?? ""; // Aggregate score is the worst project's (58, not 91). expect(frame).toContain("58"); - expect(frame).toContain("web"); - expect(frame).toContain("api"); + // Both projects' findings appear in one flat list (by rule title). + expect(frame).toContain("react-doctor/rules-of-hooks"); + expect(frame).toContain("react-doctor/no-array-index-key"); + // The selected row's full, folder-qualified path shows in the detail pane. + expect(frame).toContain("apps/web/src/Profile.tsx"); + // The project count rides the status bar instead of a navigable list. expect(frame).toContain("2 projects"); unmount(); }); @@ -176,8 +219,10 @@ describe("ScanApp", () => { const { lastFrame, stdin, unmount } = render(); await flush(); - // First row selected by default → detail pane shows the first rule's message. - expect(lastFrame() ?? "").toContain("Correctness: react-doctor/rules-of-hooks"); + // First row selected by default → detail pane shows the first rule's title + // with a dim `category · severity` tag beneath it. + expect(lastFrame() ?? "").toContain("react-doctor/rules-of-hooks"); + expect(lastFrame() ?? "").toContain("Correctness · error"); stdin.write("j"); await flush(); @@ -189,4 +234,33 @@ describe("ScanApp", () => { await flush(); unmount(); }); + + it("uses the side-by-side layout on a wide terminal", async () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [ + makeDiagnostic({ rule: "rules-of-hooks", severity: "error", category: "Correctness" }), + makeDiagnostic({ rule: "no-array-index-key", severity: "warning", category: "Bugs" }), + ], + score: SCORE, + projectedScore: null, + projectName: "demo-app", + rootDirectory: "/tmp/demo-app", + scannedFileCount: 2, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, stdout, unmount } = render(); + widenTerminal(stdout); + await flush(); + + const frame = lastFrame() ?? ""; + // The split layout draws a vertical divider between the list and the detail, + // so a row's title and its detail headline share a line. + expect(frame).toContain("│"); + expect(frame).toMatch(/react-doctor\/rules-of-hooks.*│/); + unmount(); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0141bff8a..bf3c407f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) + figures: + specifier: ^6.1.0 + version: 6.1.0 ink: specifier: ^7.1.0 version: 7.1.0(@types/react@19.2.14)(react@19.2.5) @@ -3094,6 +3097,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -6336,6 +6343,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 From 69b001dc2d3e23c52cb911e1f80afed92607af3d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Mon, 29 Jun 2026 01:53:11 -0700 Subject: [PATCH 7/8] fix --- packages/react-doctor/src/cli/ink/components/report.tsx | 4 ++-- packages/react-doctor/src/cli/ink/components/status-bar.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index c899bf2ad..84727fd4f 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -13,7 +13,7 @@ export interface ReportProps { readonly onExit: () => void; /** When set (monorepo flat view), shows a "· N projects" span in the status bar. */ readonly projectCount?: number; - /** Hint shown in the empty-state footer (e.g. "Esc back · q quit"). */ + /** Hint shown in the empty-state footer (e.g. "Esc to go back · q to quit"). */ readonly exitHint?: string; } @@ -51,7 +51,7 @@ const MIN_COLUMN_WIDTH = 20; * the left column and the detail preview fills the right column beside them; on * a narrow one everything stacks. */ -export const Report = ({ report, onExit, projectCount, exitHint = "q quit" }: ReportProps) => { +export const Report = ({ report, onExit, projectCount, exitHint = "q to quit" }: ReportProps) => { const { rows: terminalRows, columns } = useStdoutDimensions(); const diagnosticRows = useMemo( () => buildDiagnosticRows(report.diagnostics, report.score), diff --git a/packages/react-doctor/src/cli/ink/components/status-bar.tsx b/packages/react-doctor/src/cli/ink/components/status-bar.tsx index 7406884cc..b23c07e06 100644 --- a/packages/react-doctor/src/cli/ink/components/status-bar.tsx +++ b/packages/react-doctor/src/cli/ink/components/status-bar.tsx @@ -19,7 +19,7 @@ export const StatusBar = ({ position, groupCount, projectCount, - exitHint = "q quit", + exitHint = "q to quit", }: StatusBarProps) => ( @@ -39,7 +39,7 @@ export const StatusBar = ({ ) : null} {" "} - {position}/{groupCount} · ↑↓ move · {exitHint} + {position}/{groupCount} · ↑/↓ to move · {exitHint} ); From b7022f95f6982b53998c23c20c1acb7d8931580f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Mon, 29 Jun 2026 04:33:28 -0700 Subject: [PATCH 8/8] fix --- .../cli/ink/components/diagnostic-actions.tsx | 82 +++++++++++ .../cli/ink/components/diagnostic-item.tsx | 19 ++- .../cli/ink/components/diagnostic-list.tsx | 137 +++++++++++++++++- .../src/cli/ink/components/project-select.tsx | 50 +++---- .../src/cli/ink/components/report.tsx | 37 ++++- .../src/cli/ink/components/status-bar.tsx | 14 +- .../src/cli/ink/components/summary.tsx | 19 ++- .../src/cli/ink/lib/build-issue-prompt.ts | 59 ++++++++ .../react-doctor/src/cli/ink/run-scan-app.tsx | 51 ++++++- .../react-doctor/src/cli/ink/scan-app.tsx | 27 +++- .../react-doctor/src/cli/ink/scan-store.ts | 12 ++ .../react-doctor/src/cli/utils/constants.ts | 4 + .../src/cli/utils/detect-launchable-agents.ts | 15 ++ .../src/cli/utils/handoff-to-agent.ts | 14 +- 14 files changed, 466 insertions(+), 74 deletions(-) create mode 100644 packages/react-doctor/src/cli/ink/components/diagnostic-actions.tsx create mode 100644 packages/react-doctor/src/cli/ink/lib/build-issue-prompt.ts create mode 100644 packages/react-doctor/src/cli/utils/detect-launchable-agents.ts diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-actions.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-actions.tsx new file mode 100644 index 000000000..28c9f2f6e --- /dev/null +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-actions.tsx @@ -0,0 +1,82 @@ +import { getSkillAgentConfig } from "agent-install"; +import { Box, Text } from "ink"; +import type { CliAgentId } from "../../utils/launch-agent.js"; + +export interface DiagnosticActionsProps { + /** Launchable agents, in order; rendered between Copy and the read toggle. */ + readonly launchableAgents: ReadonlyArray; + /** Whether the selected issue is currently marked read. */ + readonly isRead: boolean; + /** True right after the selected issue's prompt was copied. */ + readonly justCopied: boolean; + /** Whether this pane holds focus (then `↑/↓` + Enter drive it). */ + readonly isFocused: boolean; + /** Index of the focused action (Copy = 0, agents next, read toggle last). */ + readonly focusedIndex: number; +} + +/** + * The order actions are laid out in — the single source of truth shared with + * `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; + +const ActionRow = ({ + label, + isFocused, + trailing, +}: { + readonly label: string; + readonly isFocused: boolean; + readonly trailing?: string; +}) => ( + + {isFocused ? "› " : " "} + + {label} + + {trailing ? {` ${trailing}`} : null} + +); + +/** + * The right-panel triage actions for the selected issue: copy a focused fix + * prompt, hand it to a detected CLI agent (Claude Code / Codex / Cursor), or + * flip the issue's read state. The pane is navigated by focus — `Tab` moves in, + * `↑/↓` highlights a row, Enter runs it — so there are no per-row hotkeys to + * memorize. + */ +export const DiagnosticActions = ({ + launchableAgents, + isRead, + justCopied, + isFocused, + focusedIndex, +}: DiagnosticActionsProps) => { + const toggleIndex = actionCount(launchableAgents.length) - 1; + return ( + + + Actions + {isFocused ? " ↑/↓ · enter · esc back" : " tab to focus"} + + + {launchableAgents.map((agentId, index) => ( + + ))} + + + ); +}; diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx index 2dbceeaa0..2d92e318e 100644 --- a/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-item.tsx @@ -5,22 +5,25 @@ import { severityVariant } from "../lib/severity-variants.js"; export interface DiagnosticItemProps { readonly row: DiagnosticRow; readonly isSelected: boolean; + /** Triaged ("read") rows render muted with a blank marker instead of the dot. */ + readonly isRead: 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. + * `› • ✖ Title ×N` — a `›` pointer on the selected row, an unread `•` dot + * (blanked once triaged), then the icon + title colored by severity and a dim + * `×N` site badge. Read rows render muted so the unread queue stays scannable. */ -export const DiagnosticItem = ({ row, isSelected }: DiagnosticItemProps) => { +export const DiagnosticItem = ({ row, isSelected, isRead }: DiagnosticItemProps) => { const variant = severityVariant(row.severity); return ( - - {isSelected ? "› " : " "} - {variant.icon} - + + {isSelected ? "›" : " "} + {isRead ? " " : " •"} + {` ${variant.icon} `} + {row.title} {row.siteCount > 1 ? ×{row.siteCount} : null} diff --git a/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx index d3c9149fd..f6ec20eb9 100644 --- a/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx +++ b/packages/react-doctor/src/cli/ink/components/diagnostic-list.tsx @@ -1,10 +1,14 @@ import { Box, Text, useInput } from "ink"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; +import { copyToClipboard, type CliAgentId } from "../../utils/launch-agent.js"; 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 { buildIssuePrompt } from "../lib/build-issue-prompt.js"; import type { DiagnosticRow } from "../lib/diagnostic-rows.js"; +import type { TuiHandoffRequest } from "../scan-store.js"; +import { DiagnosticActions, actionCount } from "./diagnostic-actions.js"; import { DiagnosticDetail } from "./diagnostic-detail.js"; import { DiagnosticItem } from "./diagnostic-item.js"; import { StatusBar } from "./status-bar.js"; @@ -24,6 +28,12 @@ export interface DiagnosticListProps { /** "split" renders the detail beside the list; "stacked" puts it below. */ readonly layout: DiagnosticListLayout; readonly rootDirectory: string; + /** Project name, woven into the copied / handed-off fix prompt. */ + readonly projectName: string; + /** Launchable CLI agents, in hotkey order; empty disables the run-in actions. */ + readonly launchableAgents: ReadonlyArray; + /** Hands the selected issue's prompt to an agent; the caller exits + launches. */ + readonly onHandoff?: (request: TuiHandoffRequest) => void; /** When set (monorepo flat view), surfaced in the status bar. */ readonly projectCount?: number; readonly onExit: () => void; @@ -37,6 +47,7 @@ const renderEntry = ( entry: DiagnosticListEntry, entryIndex: number, selectedIndex: number, + readRuleKeys: ReadonlySet, ): ReactNode => { if (entry.kind === "header") { return ( @@ -50,6 +61,7 @@ const renderEntry = ( key={entry.row.ruleKey} row={entry.row} isSelected={entryIndex === selectedIndex} + isRead={readRuleKeys.has(entry.row.ruleKey)} /> ); }; @@ -60,8 +72,12 @@ const renderEntry = ( * 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. + * sit in the left column and the detail + triage actions fill the right column + * beside them; on a narrow one everything stacks. + * + * Triage: the selected issue is auto-marked read (an inbox queue), `x` flips it, + * `c` copies a focused fix prompt, and `1`..`N` hand that prompt to a launchable + * agent (which takes over the terminal once the app exits). */ export const DiagnosticList = ({ header, @@ -72,41 +88,140 @@ export const DiagnosticList = ({ listHeight, layout, rootDirectory, + projectName, + launchableAgents, + onHandoff, projectCount, onExit, exitHint, }: DiagnosticListProps) => { const entries = useMemo(() => buildDiagnosticListEntries(rows), [rows]); + const [focusedPane, setFocusedPane] = useState<"list" | "actions">("list"); + const [focusedActionIndex, setFocusedActionIndex] = useState(0); + 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(); + isActive: focusedPane === "list", }); const visibleEntries = entries.slice(visibleStart, visibleEnd); const selectedEntry = entries[selectedIndex]; const selected = selectedEntry?.kind === "item" ? selectedEntry.row : null; + const selectedRuleKey = selected?.ruleKey ?? null; + const totalActions = actionCount(launchableAgents.length); + + const [readRuleKeys, setReadRuleKeys] = useState>(() => new Set()); + const [copiedRuleKey, setCopiedRuleKey] = useState(null); + + // Inbox semantics: landing on an issue marks it read. Keyed on the rule, so + // toggling it back to unread sticks until the selection moves elsewhere. + useEffect(() => { + if (!selectedRuleKey) return; + setReadRuleKeys((previous) => + previous.has(selectedRuleKey) ? previous : new Set(previous).add(selectedRuleKey), + ); + }, [selectedRuleKey]); + + const toggleSelectedRead = (): void => { + if (!selectedRuleKey) return; + setReadRuleKeys((previous) => { + const next = new Set(previous); + if (next.has(selectedRuleKey)) next.delete(selectedRuleKey); + else next.add(selectedRuleKey); + return next; + }); + }; + + const copySelectedPrompt = (): void => { + if (!selected) return; + const prompt = buildIssuePrompt({ row: selected, projectName }); + const ruleKey = selected.ruleKey; + void copyToClipboard(prompt).then((didCopy) => { + if (didCopy) setCopiedRuleKey(ruleKey); + }); + }; + + const launchSelectedInAgent = (agentId: CliAgentId): void => { + if (!selected || !onHandoff) return; + onHandoff({ agentId, prompt: buildIssuePrompt({ row: selected, projectName }) }); + onExit(); + }; + + // Focused index maps to: 0 = Copy, 1..N = launchable agents, last = read toggle + // (the same order `DiagnosticActions` renders). + const runFocusedAction = (): void => { + if (focusedActionIndex === 0) return copySelectedPrompt(); + if (focusedActionIndex <= launchableAgents.length) { + const agentId = launchableAgents[focusedActionIndex - 1]; + if (agentId) launchSelectedInAgent(agentId); + return; + } + toggleSelectedRead(); + }; + + const focusActions = (): void => { + if (!selected) return; + setFocusedActionIndex(0); + setFocusedPane("actions"); + }; + + // List pane: the viewport owns ↑/↓; here we only handle quit and the move + // into the actions pane. + useInput( + (input, key) => { + if (input === "q" || key.escape) return onExit(); + if (key.tab || key.rightArrow) return focusActions(); + }, + { isActive: focusedPane === "list" }, + ); + + // Actions pane: ↑/↓ walks the rows, Enter runs the focused one, and + // Tab/Esc/← hand focus back to the list. + useInput( + (input, key) => { + if (input === "q") return onExit(); + if (key.escape || key.leftArrow || key.tab) return setFocusedPane("list"); + if (key.upArrow || input === "k") { + return setFocusedActionIndex((index) => Math.max(0, index - 1)); + } + if (key.downArrow || input === "j") { + return setFocusedActionIndex((index) => Math.min(totalActions - 1, index + 1)); + } + if (key.return) return runFocusedAction(); + }, + { isActive: focusedPane === "actions" }, + ); + 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 unreadCount = rows.length - rows.filter((row) => readRuleKeys.has(row.ruleKey)).length; const isSplit = layout === "split"; const listColumn = ( {visibleEntries.map((entry, index) => - renderEntry(entry, visibleStart + index, selectedIndex), + renderEntry(entry, visibleStart + index, selectedIndex, readRuleKeys), )} ); + const actions = selected ? ( + + ) : null; + const statusBar = ( @@ -140,6 +259,7 @@ export const DiagnosticList = ({ paddingLeft={1} > + {actions} {statusBar} @@ -153,6 +273,7 @@ export const DiagnosticList = ({ {listColumn} {"─".repeat(width)} + {actions} {statusBar} ); diff --git a/packages/react-doctor/src/cli/ink/components/project-select.tsx b/packages/react-doctor/src/cli/ink/components/project-select.tsx index e2d8e70a1..41a21bc49 100644 --- a/packages/react-doctor/src/cli/ink/components/project-select.tsx +++ b/packages/react-doctor/src/cli/ink/components/project-select.tsx @@ -2,7 +2,6 @@ import path from "node:path"; import figures from "figures"; import { Box, Text, useInput } from "ink"; import { useMemo, useState } from "react"; -import type { ReactNode } from "react"; import type { WorkspacePackage } from "@react-doctor/core"; import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js"; import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; @@ -36,13 +35,15 @@ const clamp = (value: number, min: number, max: number): number => const isPrintable = (input: string): boolean => input.length > 0 && [...input].every((char) => char.charCodeAt(0) >= 32); -// Render the project name with its fuzzy-matched chars lit up, so it's clear why -// a result survived the filter. -const renderName = ( - name: string, - matchedIndices: ReadonlyArray, - isSelected: boolean, -): ReactNode => { +interface MatchedNameProps { + readonly name: string; + readonly matchedIndices: ReadonlyArray; + readonly isSelected: boolean; +} + +// The project name with its fuzzy-matched chars lit up, so it's clear why a +// result survived the filter. +const MatchedName = ({ name, matchedIndices, isSelected }: MatchedNameProps) => { if (matchedIndices.length === 0) { return ( @@ -202,17 +203,6 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele ...packages.map((workspacePackage) => workspacePackage.name.length), ); - // The footer's Enter clause states exactly what will run, so the empty-set - // fallback (scan the highlighted row) is never a surprise. - const enterAction = - checked.size === 0 - ? current - ? `scan ${current.name}` - : "scan" - : checked.size === packages.length - ? `scan all · ${checked.size}` - : `scan ${checked.size} selected`; - return ( @@ -249,7 +239,11 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele {isChecked ? `${figures.radioOn} ` : `${figures.radioOff} `} - {renderName(match.workspacePackage.name, match.matchedIndices, isSelected)} + {" ".repeat(longestNameLength - match.workspacePackage.name.length + 2)} {path.relative(rootDirectory, match.workspacePackage.directory) || "."} @@ -259,13 +253,15 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele }) )} - - {isSearching - ? "type to filter · enter confirm · esc clear" - : "space select · a all · / search · "} - {isSearching ? null : enter} - {isSearching ? null : ` ${enterAction} · q cancel`} - + + + {isSearching + ? "type to filter · enter confirm · esc clear" + : "space select · a all · / search · "} + {isSearching ? null : enter} + {isSearching ? null : " to submit · q cancel"} + + ); }; diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index 84727fd4f..59f788469 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from "ink"; import { useMemo } from "react"; -import type { ScanReport } from "../scan-store.js"; +import type { CliAgentId } from "../../utils/launch-agent.js"; +import type { ScanReport, TuiHandoffRequest } from "../scan-store.js"; import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js"; import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js"; import { buildDiagnosticRows } from "../lib/diagnostic-rows.js"; @@ -11,6 +12,10 @@ export interface ReportProps { readonly report: ScanReport; /** q / Esc handler that exits the app. */ readonly onExit: () => void; + /** Launchable CLI agents, in hotkey order, for the right-panel triage actions. */ + readonly launchableAgents?: ReadonlyArray; + /** Hands the selected issue's prompt to an agent; the caller exits + launches. */ + readonly onHandoff?: (request: TuiHandoffRequest) => void; /** When set (monorepo flat view), shows a "· N projects" span in the status bar. */ readonly projectCount?: number; /** Hint shown in the empty-state footer (e.g. "Esc to go back · q to quit"). */ @@ -26,9 +31,12 @@ const DETAIL_ROWS = 15; const STATUS_ROWS = 2; const DIVIDER_ROWS = 1; const LIST_MARGIN_ROWS = 1; -// Stacked (narrow): header, list, divider, detail, and status all stack. +// Triage actions block (header + copy + up to 3 agents + read toggle + margin), +// stacked below the detail on a narrow terminal. +const ACTIONS_ROWS = 7; +// Stacked (narrow): header, list, divider, detail, actions, and status all stack. const STACKED_CHROME_ROWS = - HEADER_ROWS + LIST_MARGIN_ROWS + DETAIL_ROWS + DIVIDER_ROWS + STATUS_ROWS; + HEADER_ROWS + LIST_MARGIN_ROWS + DETAIL_ROWS + DIVIDER_ROWS + ACTIONS_ROWS + STATUS_ROWS; // Split (wide): the header sits atop the list in the left column and the detail // fills the right column beside both, so only the header, the list margin, and // the status bar are reserved off the column height. @@ -51,7 +59,14 @@ const MIN_COLUMN_WIDTH = 20; * the left column and the detail preview fills the right column beside them; on * a narrow one everything stacks. */ -export const Report = ({ report, onExit, projectCount, exitHint = "q to quit" }: ReportProps) => { +export const Report = ({ + report, + onExit, + launchableAgents = [], + onHandoff, + projectCount, + exitHint = "q to quit", +}: ReportProps) => { const { rows: terminalRows, columns } = useStdoutDimensions(); const diagnosticRows = useMemo( () => buildDiagnosticRows(report.diagnostics, report.score), @@ -59,9 +74,14 @@ export const Report = ({ report, onExit, projectCount, exitHint = "q to quit" }: ); useExitOnCtrlC(); - useInput((input, key) => { - if (input === "q" || key.escape) onExit(); - }); + // Only the empty-state view below owns q/Esc; once there are rows the + // DiagnosticList handles input (Esc there means "leave the actions pane"). + useInput( + (input, key) => { + if (input === "q" || key.escape) onExit(); + }, + { isActive: diagnosticRows.length === 0 }, + ); const width = Math.max(MIN_WIDTH, columns - 2); const isWide = columns >= WIDE_LAYOUT_MIN_COLUMNS && terminalRows >= WIDE_LAYOUT_MIN_ROWS; @@ -108,6 +128,9 @@ export const Report = ({ report, onExit, projectCount, exitHint = "q to quit" }: listHeight={listHeight} layout={isWide ? "split" : "stacked"} rootDirectory={report.rootDirectory} + projectName={report.projectName} + launchableAgents={launchableAgents} + onHandoff={onHandoff} projectCount={projectCount} onExit={onExit} exitHint={exitHint} diff --git a/packages/react-doctor/src/cli/ink/components/status-bar.tsx b/packages/react-doctor/src/cli/ink/components/status-bar.tsx index b23c07e06..52a100d0a 100644 --- a/packages/react-doctor/src/cli/ink/components/status-bar.tsx +++ b/packages/react-doctor/src/cli/ink/components/status-bar.tsx @@ -6,8 +6,12 @@ export interface StatusBarProps { readonly warningCount: number; readonly position: number; readonly groupCount: number; + /** Issues not yet triaged ("read") — shown as a "· N unread" span. */ + readonly unreadCount?: number; /** When set (monorepo flat view), shows a "· N projects" span. */ readonly projectCount?: number; + /** Context-sensitive navigation hint (e.g. "↑/↓ move · tab actions"). */ + readonly keyHints?: string; readonly exitHint?: string; } @@ -18,7 +22,9 @@ export const StatusBar = ({ warningCount, position, groupCount, + unreadCount, projectCount, + keyHints = "↑/↓ to move", exitHint = "q to quit", }: StatusBarProps) => ( @@ -31,6 +37,12 @@ export const StatusBar = ({ {warningCount} warnings + {unreadCount !== undefined ? ( + 0 ? "cyan" : undefined} dimColor={unreadCount === 0}> + {" · "} + {unreadCount} unread + + ) : null} {projectCount !== undefined ? ( {" · "} @@ -39,7 +51,7 @@ export const StatusBar = ({ ) : null} {" "} - {position}/{groupCount} · ↑/↓ to move · {exitHint} + {position}/{groupCount} · {keyHints} · {exitHint} ); diff --git a/packages/react-doctor/src/cli/ink/components/summary.tsx b/packages/react-doctor/src/cli/ink/components/summary.tsx index 6cbc17af6..70ff7da8e 100644 --- a/packages/react-doctor/src/cli/ink/components/summary.tsx +++ b/packages/react-doctor/src/cli/ink/components/summary.tsx @@ -1,9 +1,14 @@ -import type { MultiProjectSummary, ScanReport } from "../scan-store.js"; +import type { CliAgentId } from "../../utils/launch-agent.js"; +import type { MultiProjectSummary, ScanReport, TuiHandoffRequest } from "../scan-store.js"; import { Report } from "./report.js"; export interface SummaryProps { readonly summary: MultiProjectSummary; readonly onExit: () => void; + /** Launchable CLI agents for the report's right-panel triage actions. */ + readonly launchableAgents?: ReadonlyArray; + /** Hands the selected issue's prompt to an agent; the caller exits + launches. */ + readonly onHandoff?: (request: TuiHandoffRequest) => void; } /** @@ -13,7 +18,7 @@ export interface SummaryProps { * the shared root resolves every code frame, so this is just the single-project * `Report` fed the combined diagnostics plus the aggregate (worst) score. */ -export const Summary = ({ summary, onExit }: SummaryProps) => { +export const Summary = ({ summary, onExit, launchableAgents, onHandoff }: SummaryProps) => { const report: ScanReport = { diagnostics: summary.combinedDiagnostics, score: summary.aggregateScore, @@ -25,5 +30,13 @@ export const Summary = ({ summary, onExit }: SummaryProps) => { isOffline: summary.isOffline, noScoreMessage: summary.noScoreMessage, }; - return ; + return ( + + ); }; diff --git a/packages/react-doctor/src/cli/ink/lib/build-issue-prompt.ts b/packages/react-doctor/src/cli/ink/lib/build-issue-prompt.ts new file mode 100644 index 000000000..3f5db923b --- /dev/null +++ b/packages/react-doctor/src/cli/ink/lib/build-issue-prompt.ts @@ -0,0 +1,59 @@ +import { formatFixRecipeLine, formatLearnMoreLine } from "../../utils/diagnostic-grouping.js"; +import { TUI_ISSUE_PROMPT_MAX_SITES } from "../../utils/constants.js"; +import type { DiagnosticRow } from "./diagnostic-rows.js"; + +export interface BuildIssuePromptInput { + readonly row: DiagnosticRow; + readonly projectName: string; +} + +const formatSite = (diagnostic: DiagnosticRow["representative"]): string => + diagnostic.line > 0 ? `${diagnostic.filePath}:${diagnostic.line}` : diagnostic.filePath; + +/** + * A focused, single-rule fix prompt for the interactive report's triage actions + * — the selected rule group, its impact, the canonical fix-recipe directive, and + * up to N affected sites. Mirrors the CLI triage prompt's shape so copying from + * the TUI hands an agent the same high-signal, single-task instruction. + */ +export const buildIssuePrompt = ({ row, projectName }: BuildIssuePromptInput): string => { + const { representative } = row; + const severityLabel = row.severity === "error" ? "ERROR" : "WARN"; + const uniqueSites = [...new Set(row.diagnostics.map(formatSite))]; + const inlineSites = uniqueSites.slice(0, TUI_ISSUE_PROMPT_MAX_SITES); + const remainingSiteCount = uniqueSites.length - inlineSites.length; + + const lines = [ + `Fix exactly one React Doctor rule in ${projectName}:`, + "", + `${severityLabel} ${row.category}: ${row.title} (${row.ruleKey}, ×${row.siteCount})`, + representative.message, + ]; + + if (representative.help) lines.push("", `Suggested fix: ${representative.help}`); + + const fixRecipeLine = formatFixRecipeLine(representative); + if (fixRecipeLine) lines.push("", fixRecipeLine); + + lines.push( + "", + "Scope:", + `- Fix only ${row.ruleKey}.`, + "- Fix the root cause; do not suppress, disable, or silence the rule.", + "- Keep unrelated refactors out of this pass.", + "", + "Affected sites:", + ...inlineSites.map((site) => `- ${site}`), + ); + if (remainingSiteCount > 0) lines.push(`- +${remainingSiteCount} more sites`); + + const learnMoreLine = formatLearnMoreLine(representative); + if (learnMoreLine) lines.push("", learnMoreLine); + + lines.push( + "", + `Verify with \`npx react-doctor@latest --verbose\` and confirm ${row.ruleKey} is gone before moving on.`, + ); + + return lines.join("\n"); +}; diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index da93c31a3..d2f93122b 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -18,10 +18,12 @@ import { discoverWorkspacePackages, selectProjects } from "../utils/select-proje import { isCiEnvironment } from "../utils/is-ci-environment.js"; import { formatElapsedTime } from "../utils/render-diagnostics.js"; import { printFooter } from "../utils/render-summary.js"; +import { detectLaunchableAgents } from "../utils/detect-launchable-agents.js"; +import { CLI_AGENT_BINARIES, launchCliAgent } from "../utils/launch-agent.js"; import { ProjectSelect } from "./components/project-select.js"; import { ScanApp } from "./scan-app.js"; import { createScanStore } from "./scan-store.js"; -import type { MultiProjectSummary, ScanReport } from "./scan-store.js"; +import type { MultiProjectSummary, ScanReport, TuiHandoffRequest } from "./scan-store.js"; export interface RunScanAppInput { readonly directory: string; @@ -202,12 +204,42 @@ const printExitFooter = async (input: ExitFooterInput): Promise => { ); }; +// Fulfills a triage handoff once the Ink app has unmounted (so the agent CLI +// can take over this TTY). On launch failure the prompt is printed instead, so +// the user can paste it into any agent — mirroring the post-scan handoff. +const performTuiHandoff = async ( + request: TuiHandoffRequest, + rootDirectory: string, +): Promise => { + try { + await launchCliAgent(request.agentId, request.prompt, rootDirectory); + } catch { + process.stdout.write( + `${highlighter.warn("⚠")} Couldn't launch ${CLI_AGENT_BINARIES[request.agentId]}. Here's the prompt instead:\n`, + ); + process.stdout.write(`${highlighter.dim("──── Agent prompt ────")}\n`); + process.stdout.write(`${request.prompt}\n`); + process.stdout.write(`${highlighter.dim("──────────────────────")}\n`); + } +}; + const runSingleProjectScan = async ( directory: string, input: RunScanAppInput, ): Promise => { const store = createScanStore(); - const instance = render(, { exitOnCtrlC: false }); + const launchableAgents = await detectLaunchableAgents(); + let pendingHandoff: TuiHandoffRequest | null = null; + const instance = render( + { + pendingHandoff = request; + }} + />, + { exitOnCtrlC: false }, + ); const isOffline = resolveIsOffline(input); const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); @@ -220,6 +252,7 @@ const runSingleProjectScan = async ( toScanReport({ result, rootDirectory: directory, projectedScore, isOffline, noScoreMessage }), ); await instance.waitUntilExit(); + if (pendingHandoff) await performTuiHandoff(pendingHandoff, directory); await printExitFooter({ diagnostics: result.diagnostics, scoreResult: result.score, @@ -245,7 +278,18 @@ const runMultiProjectScan = async ( input: RunScanAppInput, ): Promise => { const store = createScanStore(); - const instance = render(, { exitOnCtrlC: false }); + const launchableAgents = await detectLaunchableAgents(); + let pendingHandoff: TuiHandoffRequest | null = null; + const instance = render( + { + pendingHandoff = request; + }} + />, + { exitOnCtrlC: false }, + ); const isOffline = resolveIsOffline(input); const noScoreMessage = buildNoScoreMessage(input.options?.noScore === true); @@ -312,6 +356,7 @@ const runMultiProjectScan = async ( }; store.setSummary(summary); await instance.waitUntilExit(); + if (pendingHandoff) await performTuiHandoff(pendingHandoff, rootDirectory); await printExitFooter({ diagnostics: combinedDiagnostics, scoreResult: summary.aggregateScore, diff --git a/packages/react-doctor/src/cli/ink/scan-app.tsx b/packages/react-doctor/src/cli/ink/scan-app.tsx index b771a744b..27668f35a 100644 --- a/packages/react-doctor/src/cli/ink/scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/scan-app.tsx @@ -1,27 +1,46 @@ import { useApp } from "ink"; +import type { CliAgentId } from "../utils/launch-agent.js"; import { Report } from "./components/report.js"; import { Scanning } from "./components/scanning.js"; import { Summary } from "./components/summary.js"; import { useScanStore } from "./hooks/use-scan-store.js"; -import type { ScanStore } from "./scan-store.js"; +import type { ScanStore, TuiHandoffRequest } from "./scan-store.js"; export interface ScanAppProps { readonly store: ScanStore; + /** Launchable CLI agents for the report's right-panel triage actions. */ + readonly launchableAgents?: ReadonlyArray; + /** Records an agent handoff request the runner fulfills after the app exits. */ + readonly onHandoff?: (request: TuiHandoffRequest) => void; } const RECENT_LIVE_COUNT = 5; /** Root of the interactive scan UI: routes the store phase to a view. */ -export const ScanApp = ({ store }: ScanAppProps) => { +export const ScanApp = ({ store, launchableAgents, onHandoff }: ScanAppProps) => { const snapshot = useScanStore(store); const { exit } = useApp(); if (snapshot.phase === "summary" && snapshot.summary) { - return exit()} />; + return ( + exit()} + /> + ); } if (snapshot.phase === "report" && snapshot.report) { - return exit()} />; + return ( + exit()} + /> + ); } return ( diff --git a/packages/react-doctor/src/cli/ink/scan-store.ts b/packages/react-doctor/src/cli/ink/scan-store.ts index 0eab25ce8..490dd0942 100644 --- a/packages/react-doctor/src/cli/ink/scan-store.ts +++ b/packages/react-doctor/src/cli/ink/scan-store.ts @@ -4,8 +4,20 @@ import type { Diagnostic, ScoreResult } from "@react-doctor/core"; // nested-array readonly-ness. The settled `report` keeps the index type. import type { Diagnostic as LiveDiagnostic } from "@react-doctor/core/schemas"; +import type { CliAgentId } from "../utils/launch-agent.js"; + export type ScanPhase = "scanning" | "report" | "summary" | "done"; +/** + * A request to hand the selected issue's fix prompt to a CLI agent. Produced by + * the report's triage actions and fulfilled by the runner AFTER the Ink app + * unmounts — the agent inherits this TTY, so the TUI must yield it first. + */ +export interface TuiHandoffRequest { + readonly agentId: CliAgentId; + readonly prompt: string; +} + export type ProgressStatus = "active" | "succeeded" | "failed"; export interface ProgressState { diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index 6f19d7e57..bd8cc16e1 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -48,6 +48,10 @@ export const GH_PR_LIST_MAX = 100; // compact, passable CLI argument. export const HANDOFF_MAX_FILES_PER_RULE = 3; +// Cap on sites listed inline in the interactive report's per-issue triage +// prompt, so a single rule with hundreds of sites still copies a tight prompt. +export const TUI_ISSUE_PROMPT_MAX_SITES = 8; + // Social proof for the "Add to CI" pitch (shown in the post-scan handoff // prompt and embedded in the agent-handoff prompt). export const CI_TRUST_COMPANIES = "PayPal, Rippling, and Alibaba"; diff --git a/packages/react-doctor/src/cli/utils/detect-launchable-agents.ts b/packages/react-doctor/src/cli/utils/detect-launchable-agents.ts new file mode 100644 index 000000000..01cc823e9 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/detect-launchable-agents.ts @@ -0,0 +1,15 @@ +import { detectAvailableAgents } from "./detect-agents.js"; +import { isCommandAvailable } from "./is-command-available.js"; +import { CLI_AGENT_BINARIES, type CliAgentId } from "./launch-agent.js"; + +// CLI agents we can launch: detected as installed by `agent-install` +// (filesystem config dir) AND with their launch binary on PATH (since we +// hand the prompt to that CLI). `agent-install` has no command-availability +// check, so `isCommandAvailable` covers the launchability half. Returned in +// `CLI_AGENT_BINARIES` order so callers get a stable agent ordering. +export const detectLaunchableAgents = async (): Promise => { + const detected = new Set(await detectAvailableAgents()); + return (Object.keys(CLI_AGENT_BINARIES) as CliAgentId[]).filter( + (agentId) => detected.has(agentId) && isCommandAvailable(CLI_AGENT_BINARIES[agentId]), + ); +}; diff --git a/packages/react-doctor/src/cli/utils/handoff-to-agent.ts b/packages/react-doctor/src/cli/utils/handoff-to-agent.ts index 4ca6d67a5..5f029b1fd 100644 --- a/packages/react-doctor/src/cli/utils/handoff-to-agent.ts +++ b/packages/react-doctor/src/cli/utils/handoff-to-agent.ts @@ -4,7 +4,7 @@ import type { Diagnostic } from "@react-doctor/core"; import { highlighter } from "@react-doctor/core"; import { buildHandoffPayload } from "./build-handoff-payload.js"; import { cliLogger as logger } from "./cli-logger.js"; -import { detectAvailableAgents } from "./detect-agents.js"; +import { detectLaunchableAgents } from "./detect-launchable-agents.js"; import { findNearestPackageDirectory } from "./install-doctor-script.js"; import { isReactDoctorWorkflowInstalled, @@ -20,7 +20,6 @@ import { askAddToGitHubActions } from "./ask-add-to-github-actions.js"; import { askUpgradeActionVersion } from "./ask-upgrade-action-version.js"; import { setUpGitHubActions } from "./set-up-github-actions.js"; import { installReactDoctorSkillForAgent } from "./install-skill-for-agent.js"; -import { isCommandAvailable } from "./is-command-available.js"; import { METRIC } from "./constants.js"; import { openWorkflowPullRequest, stageWorkflowFile } from "./open-workflow-pull-request.js"; import { recordCount } from "./record-metric.js"; @@ -161,17 +160,6 @@ const maybeOfferActionUpgrade = async (projectRoot: string): Promise => { if (didApplyUpgrade) recordActionUpgradeDecision(projectRoot, "accepted"); }; -// CLI agents we can launch: detected as installed by `agent-install` -// (filesystem config dir) AND with their launch binary on PATH (since we -// hand the prompt to that CLI). `agent-install` has no command-availability -// check, so `isCommandAvailable` covers the launchability half. -const detectLaunchableAgents = async (): Promise => { - const detected = new Set(await detectAvailableAgents()); - return (Object.keys(CLI_AGENT_BINARIES) as CliAgentId[]).filter( - (agentId) => detected.has(agentId) && isCommandAvailable(CLI_AGENT_BINARIES[agentId]), - ); -}; - // Two-phase post-scan handoff: first asks whether to wire up GitHub Actions // (skipped when the workflow file is already on disk — that option would be a // no-op), then asks where to send the diagnostics for triage. The split keeps