-
Notifications
You must be signed in to change notification settings - Fork 421
feat(cli): add experimental interactive Ink TUI #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6e70c9d
b0c28d6
a5b4c4c
6465f09
181e47a
d46a322
69b001d
b7022f9
db4a840
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "react-doctor": minor | ||
| --- | ||
|
|
||
| Add an experimental interactive TUI (`react-doctor experimental-tui`) built on Ink. It streams diagnostics live during the scan, then renders a scrollable, score-sorted report (no top-3 truncation) with the doctor-face score header, score projection ("you could improve +X%"), per-category breakdown, and an inline syntax-highlighted code frame for the selected issue. It also supports monorepo project selection (an interactive multiselect) and a multi-project summary view with drill-in per project. Ink/React load lazily so the default static, JSON, and score-only paths are unaffected. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -401,6 +401,31 @@ program | |
| .allowUnknownOption() | ||
| .action(() => {}); | ||
|
|
||
| // Gated behind `experimental-` while the interactive Ink report stabilizes | ||
| // (keymap, layout, and the scan-store contract may change). Ink + React load | ||
| // lazily here so the default static path never pays their startup cost. | ||
| program | ||
| .command("experimental-tui [directory]") | ||
| .description("[experimental] interactive, scrollable scan report") | ||
| .option("--no-dead-code", "skip dead-code analysis") | ||
| .option("--no-score", "skip the score API, the share URL, and crash reporting") | ||
| .option("-p, --project <names>", "scan specific workspace projects (comma-separated, or *)") | ||
| .option("-y, --yes", "skip the project prompt and scan every discovered project") | ||
| .action( | ||
| async ( | ||
| directory = ".", | ||
| options: { deadCode?: boolean; score?: boolean; project?: string; yes?: boolean }, | ||
| ) => { | ||
| const { runScanApp } = await import("./ink/run-scan-app.js"); | ||
| await runScanApp({ | ||
| directory, | ||
| options: { deadCode: options.deadCode ?? true, noScore: options.score === false }, | ||
| projectFlag: options.project, | ||
| skipPrompts: options.yes ?? false, | ||
| }); | ||
| }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TUI always exits zeroMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit d46a322. Configure here. |
||
| ); | ||
|
|
||
| // HACK: when stdout is piped into a process that closes early (e.g. | ||
| // `react-doctor . | head`), Node throws an uncaught EPIPE on the next | ||
| // write. Exit cleanly instead of dumping a stack trace. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { Box, Text } from "ink"; | ||
|
|
||
| export interface DiagnosticActionMenuProps { | ||
| /** The selected issue's title, shown so the bar keeps its context. */ | ||
| readonly title: string; | ||
| /** Action labels in order — Copy first, then each launchable agent. */ | ||
| readonly itemLabels: ReadonlyArray<string>; | ||
| readonly focusedIndex: number; | ||
| /** Width the bar spans, so it reads as a fixed bottom strip. */ | ||
| readonly width: number; | ||
| } | ||
|
|
||
| /** | ||
| * The triage action bar: a fixed, hovering strip pinned to the bottom of the | ||
| * report (just above the status bar) while the menu is open. Lays the actions | ||
| * out horizontally — copy a focused fix prompt or hand it to a detected CLI | ||
| * agent — navigated with `←/→` and chosen with Enter, so the detail above stays | ||
| * visible the whole time. | ||
| */ | ||
| export const DiagnosticActionMenu = ({ | ||
| title, | ||
| itemLabels, | ||
| focusedIndex, | ||
| width, | ||
| }: DiagnosticActionMenuProps) => ( | ||
| <Box width={width} borderStyle="round" borderColor="cyan" paddingX={1} flexDirection="column"> | ||
| <Text wrap="truncate-end"> | ||
| <Text dimColor>Fix </Text> | ||
| <Text bold>{title}</Text> | ||
| </Text> | ||
| <Text wrap="truncate-end"> | ||
| {itemLabels.map((label, index) => { | ||
| const isFocused = index === focusedIndex; | ||
| return ( | ||
| <Text key={label}> | ||
| {index > 0 ? <Text dimColor>{" "}</Text> : null} | ||
| <Text color={isFocused ? "cyan" : undefined} bold={isFocused} dimColor={!isFocused}> | ||
| {isFocused ? `› ${label}` : ` ${label}`} | ||
| </Text> | ||
| </Text> | ||
| ); | ||
| })} | ||
| </Text> | ||
| </Box> | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { getCategoryImpact } from "@react-doctor/core"; | ||
| import { Box, Text } from "ink"; | ||
| import { useMemo } from "react"; | ||
| import { buildCodeFrame } from "../../utils/build-code-frame.js"; | ||
| import type { DiagnosticRow } from "../lib/diagnostic-rows.js"; | ||
| import { severityVariant } from "../lib/severity-variants.js"; | ||
|
|
||
| export interface DiagnosticDetailProps { | ||
| readonly row: DiagnosticRow | null; | ||
| readonly rootDirectory: string; | ||
| } | ||
|
|
||
| // Body lines hang-indent via container padding (not a leading string) so a | ||
| // wrapped continuation line lines up under the first line instead of falling | ||
| // back to column 0. | ||
| const DETAIL_INDENT_COLUMNS = 4; | ||
|
|
||
| /** | ||
| * Detail for the selected rule group, styled after the CLI's rule block | ||
| * (`render-diagnostics.ts`): icon + headline, impact prose, the dim `→` fix, | ||
| * the gray location, and the syntax-highlighted source frame at the site. | ||
| */ | ||
| export const DiagnosticDetail = ({ row, rootDirectory }: DiagnosticDetailProps) => { | ||
| const codeFrame = useMemo(() => { | ||
| if (!row) return null; | ||
| const { representative } = row; | ||
| return buildCodeFrame({ | ||
| filePath: representative.filePath, | ||
| line: representative.line, | ||
| column: representative.column, | ||
| rootDirectory, | ||
| }); | ||
| }, [row, rootDirectory]); | ||
|
|
||
| if (!row) return null; | ||
| const variant = severityVariant(row.severity); | ||
| const { representative } = row; | ||
| const impact = getCategoryImpact(row.category); | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Text wrap="truncate-end"> | ||
| <Text color={variant.color}> | ||
| {" "} | ||
| {variant.icon}{" "} | ||
| </Text> | ||
| <Text color={variant.color} bold> | ||
| {row.title} | ||
| </Text> | ||
| {row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null} | ||
| </Text> | ||
| <Box flexDirection="column" paddingLeft={DETAIL_INDENT_COLUMNS}> | ||
| <Text dimColor wrap="truncate-end"> | ||
| {row.category} · {variant.label} | ||
| </Text> | ||
| {impact ? ( | ||
| <Text dimColor wrap="wrap"> | ||
| {impact} | ||
| </Text> | ||
| ) : null} | ||
| <Text wrap="wrap">{representative.message}</Text> | ||
| <Text dimColor wrap="truncate-end"> | ||
| {row.location} | ||
| </Text> | ||
| </Box> | ||
| {codeFrame ? ( | ||
| <Box | ||
| marginTop={1} | ||
| marginLeft={DETAIL_INDENT_COLUMNS} | ||
| borderStyle="round" | ||
| borderColor="gray" | ||
| paddingX={1} | ||
| alignSelf="flex-start" | ||
| > | ||
| <Text>{codeFrame}</Text> | ||
| </Box> | ||
| ) : null} | ||
| {representative.help ? ( | ||
| <Box marginTop={1} paddingLeft={DETAIL_INDENT_COLUMNS}> | ||
| <Text dimColor wrap="wrap"> | ||
| → {representative.help} | ||
| </Text> | ||
| </Box> | ||
| ) : null} | ||
| {row.learnMore ? ( | ||
| <Box marginTop={1} paddingLeft={DETAIL_INDENT_COLUMNS}> | ||
| <Text color="cyan" wrap="truncate-end"> | ||
| {row.learnMore} | ||
| </Text> | ||
| </Box> | ||
| ) : null} | ||
| </Box> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { Text } from "ink"; | ||
| import type { DiagnosticRow } from "../lib/diagnostic-rows.js"; | ||
| import { severityVariant } from "../lib/severity-variants.js"; | ||
|
|
||
| export interface DiagnosticItemProps { | ||
| readonly row: DiagnosticRow; | ||
| readonly isSelected: boolean; | ||
| /** 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` — 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, isRead }: DiagnosticItemProps) => { | ||
| const variant = severityVariant(row.severity); | ||
|
|
||
| return ( | ||
| <Text wrap="truncate-end" dimColor={isRead}> | ||
| <Text color={isSelected ? variant.color : undefined}>{isSelected ? "›" : " "}</Text> | ||
| <Text color={isRead ? undefined : variant.color}>{isRead ? " " : " •"}</Text> | ||
| <Text color={isRead ? undefined : variant.color}>{` ${variant.icon} `}</Text> | ||
| <Text color={isRead ? undefined : variant.color} bold={isSelected}> | ||
| {row.title} | ||
| </Text> | ||
| {row.siteCount > 1 ? <Text dimColor> ×{row.siteCount}</Text> : null} | ||
| </Text> | ||
| ); | ||
| }; |


Uh oh!
There was an error while loading. Please reload this page.