feat(react-doctor): interactive code-health TUI as a tui subcommand#173
feat(react-doctor): interactive code-health TUI as a tui subcommand#173aidenybai wants to merge 23 commits into
tui subcommand#173Conversation
Adds a structured event stream to scan() so external consumers (like the upcoming TUI) can observe project detection, lint / dead-code progress, score resolution, and completion without parsing stdout. Stdout reporting and existing tests are unchanged when no reporter is supplied. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Adds a new workspace package that provides an interactive React code-health terminal UI built on Ink. Two screens (dashboard and review) share a single useReducer store driven by react-doctor's ScanReporter event stream. Features: - Animated doctor face whose mood tracks the score - Tween-animated 0-100 score gauge with delta and history sparkline - Live progress checklist that mirrors the lint / dead-code / score pipeline as it runs - Master/detail diagnostic browser with rule list, source snippet preview, and severity-aware coloring - Filter, navigation, and help overlay with full keyboard shortcuts - Watch mode (chokidar) that rescans on save with debounce - TTY guard so non-interactive shells fall back to the standard CLI Includes 17 tests across reducer logic, grouping/filtering utilities, and end-to-end Ink render smoke tests via ink-testing-library. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Adds two new subcommands to the react-doctor CLI:
react-doctor watch [dir] — open the live TUI dashboard with
file-watch rescans
react-doctor review [dir] — open the diagnostic browser TUI
Both delegate to the optional react-doctor-tui package via a
runtime-computed dynamic import so the default react-doctor
install stays lean. If the TUI package isn't installed, the CLI
prints a helpful hint pointing at npm install / npx
react-doctor-tui.
Also documents the TUI in the root README.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…e dashboard
The interactive TUI now lives at packages/react-doctor/src/tui/
instead of in a sibling package. Users invoke it with:
react-doctor tui [dir] # dashboard
react-doctor tui [dir] --watch # rescan on save
react-doctor tui [dir] --review # open the diagnostic browser
The TUI bundle is built as a separate vite-plus 'tui' entry and is
lazy-loaded by the CLI via dynamic import. Ink, React, chokidar,
and ink-spinner are kept out of the default startup path, so
non-TUI scans don't pay for those modules. Also exposes runTui
via the new react-doctor/tui export for programmatic use.
Dashboard redesign (health-app feel):
- Bordered, titled tiles for Health, Vitals, Top issues,
Categories. The Health tile holds the doctor face + score gauge;
the Vitals tile shows error / warning / file counts and last-scan
metadata.
- Progress checklist is now hidden once a scan completes — replaced
by a one-line summary footer ('✓ Last scan 2.3s · no issues').
- During the very first scan it's swapped in for the Vitals tile;
for re-scans it's hidden entirely and a 'rescanning…' indicator
is appended to the summary footer.
- Errored scans render a prominent red ErrorBanner above the
remaining tiles with a hint pointing at the recovery actions.
Doctor face fix:
- Each frame's eyes and mouth are now exactly 5 characters wide,
so '│{eyes}│' and '│{mouth}│' all render as 7-character rows
beneath the '┌─────┐' / '└─────┘' borders. The width mismatch
between the eye row (7 chars) and the mouth row (5 chars) was
the cause of the 'first 1-2 lines show double' redraw artifact.
- Slows the scanning frame interval from 240ms to 480ms to give
Ink room to fully repaint between frames.
- Plugs a setTimeout leak in the blink scheduler.
Responsive layout:
- New useTerminalSize hook subscribes to stdout 'resize' events
and re-renders the dashboard / review on every change.
- Wide (>=90 cols): tiles arranged side-by-side.
- Narrow (60-89): tiles stack vertically; review panes split
master/detail vertically; score history sparkline hidden;
header drops framework / language metadata.
- Score bar width and category-bar count adapt to available space.
Tests added:
- tests/tui/doctor-face.test.tsx: every frame across every mood is
exactly 5 chars; the rendered face is always 4 lines × 7 chars.
- tests/tui/dashboard-view.test.tsx: tiles appear after complete,
progress hidden after complete, Scanning… tile appears for the
first scan, rescanning indicator on re-scan, error banner on
failure, stacked vs. side-by-side layout per breakpoint.
- tests/tui/responsive-layout.test.tsx: dashboard renders without
throwing at every common width, sparkline hidden when narrow,
review panes degrade to stacked when narrow.
Removes the standalone react-doctor-tui workspace package and the
old watch / review subcommands (replaced by 'tui --watch' /
'tui --review').
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
tui subcommand
| score: event.result.score.score, | ||
| diagnosticCount: event.result.diagnostics.length, | ||
| timestamp: Date.now(), | ||
| } |
…d error handling Updated error handling in the react-doctor package to utilize a centralized logger instead of console.error. This change enhances consistency in error reporting across various diagnostic functions, including linting and dead code analysis. Additionally, introduced new utility functions for writing diagnostics to a temporary directory and generating shareable URLs for results in the react-doctor-tui package. Also, adjusted the dashboard and review views to display the diagnostics output path and share URL when available, improving user feedback on scan results.
…directory creation Refactored the import statements in app.tsx to use default imports for better readability. Updated the writeDiagnosticsDirectory function to utilize the new import style, improving consistency in the codebase. This change enhances the clarity of the file structure and maintains the functionality of creating a temporary diagnostics directory.
The previous tile-and-vitals layout had four bordered cards
competing for attention, a trend sparkline that meant nothing on
the first scan, a categories chart that duplicated the top-issues
list, and a header line crammed with framework / language /
TS / watching status that the user already knows. None of it told
them what to fix.
The dashboard now answers three questions, in order:
1. How healthy is the codebase? → score gauge with delta
2. What's most broken? → focused issue with rule,
message, help, file:line, and
a 7-line source snippet
3. What else? → compact list of next rules
with site counts (no repeats)
Plus a single-line footer with last-scan time, total issue count,
file count, and (if active) watching / rescanning / offline flags.
Also:
- Header collapses to just the project path (home-dir aware: shown
as ~/foo/bar when applicable). Framework / React version / TS /
watching status are surfaced where they're actually useful (the
footer for watching, nowhere for the rest — they don't drive
any user decision).
- Doctor face stays as the 4-line ASCII box (cute branding intact),
rendered alongside the score; mood tracks the score and animates
while scanning.
- Scanning-state shows a single inline spinner with the active step
and a 'M/N' progress counter instead of a 10-row checklist.
- Score gauge: dropped the trend sparkline and offline note; just
score / 100, label, delta, bar.
- Empty-state: '✓ No issues detected — nice work.' when a clean
scan completes.
- Error state: red one-line banner above the score gauge with a
recovery hint.
- Status bar: '[w] watch on/off' reflects the current mode.
Removes seven now-unused components (Tile, HealthTile, VitalsTile,
TopIssuesTile, CategoriesTile, ProgressTile, CategoryBars,
ProgressChecklist) and their helper utilities (category-breakdown,
score-bar-segments). Adds three focused replacements
(FocusedIssue, CompactIssueList, InlineProgress).
Tests rewritten for the new layout — still 655 passing, with
seven focused-dashboard cases (focus rule, no-repeat list, hidden
checklist, inline progress, rescanning indicator, empty state,
error banner) plus a responsive-bar-width assertion that replaced
the trend-sparkline test.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
… visibility Modified the sorting of rule groups in the diagnostics output to print errors last, ensuring they are visible first when the terminal scrolls to the bottom. This change enhances the user experience by prioritizing the display of less severe issues above the score box.
…lionco/react-doctor into cursor/react-doctor-tui-50e9 Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…nostics directory creation" This reverts commit 2d98d6c.
… improved error handling" This reverts commit d60277e.
…lionco/react-doctor into cursor/react-doctor-tui-50e9 Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
The standard CLI prompts via the prompts library when multiple
React workspace packages are detected, but the TUI owns the TTY,
so the prompt was being skipped and the TUI silently scanned the
monorepo root (which usually has no React deps and surfaced as
'No React dependency found').
Adds a ProjectPicker screen that runs before the dashboard. It
reuses the same workspace-discovery logic as the standard CLI
(listWorkspacePackages → discoverReactSubprojects fallback):
- 0 packages discovered → scan the rootDirectory
- 1 package discovered → auto-select it (no prompt)
- 2+ packages → render the picker
Picker UX:
Multiple React projects found. Pick one to scan:
ami packages/ami
▸ admin packages/admin
docs packages/docs
[↑↓] move [↵] scan this project [q] quit
Single-select (single project per session) keeps the dashboard
focused on one codebase. Users who want to scan a specific package
non-interactively can pass --project <name> to skip the picker.
Plumbing:
- Adds selectedDirectory / workspacePackages / workspaceCursor
to AppState, plus three reducer actions
(set-workspace-packages, navigate-workspace, select-workspace).
Scanning is gated on selectedDirectory being non-null.
- useInput routes ↑↓/j/k/enter to the picker when it's visible
and falls through to dashboard / review shortcuts otherwise.
- The status bar is hidden during picker mode (the picker has
its own footer hint).
- --project name|basename forwards from the CLI subcommand
through runTui to the App and short-circuits the picker.
Tests:
- Picker rendering: every package + its relative directory; cursor
marker on the highlighted row; keyboard hints.
- Reducer transitions: set-workspace-packages stores + resets
cursor; navigate-workspace clamps; select-workspace gates
scanning by setting selectedDirectory.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
| const sitesCount = rule.diagnostics.length; | ||
| const firstSite = rule.diagnostics[0]; | ||
| const snippet = | ||
| firstSite && firstSite.line > 0 ? readSourceSnippet(firstSite.filePath, firstSite.line) : null; |
There was a problem hiding this comment.
Synchronous file I/O on every render without memoization
Medium Severity
readSourceSnippet (which calls fs.readFileSync) is invoked directly in the FocusedIssue render body without useMemo. Every parent re-render triggers a blocking disk read. The sibling component DiagnosticDetail correctly wraps the same call in useMemo. During re-scans in watch mode, scan-event dispatches cause frequent re-renders, each one blocking on synchronous I/O.
Reviewed by Cursor Bugbot for commit 6caeccc. Configure here.
…idths
The review screen was unusable at ~80 cols: rule names like
'react-doctor/rerender-dependencies' wrapped mid-word
('react-doctor/rerender-depend\nncies'), the detail-pane title
broke (e.g. 'react-doctor/no-disabled-z' followed by 'om' on the
next line), file paths spilled across two lines, and source-snippet
content overflowed past the pane boundary.
Fixes:
- Stack the review master/detail panes vertically below 90 cols
(was 60 cols). At 60-89 cols, splitting 40/60 left both panes
too narrow to fit a typical 'react-doctor/<rule-name>' identifier.
- Compute pane widths in ReviewView and pass them down. List
ellipsizes rule names with '…' to fit. Detail header truncates
the rule key, then renders 'severity · category' on its own line
so the rule identifier always stays on one row.
- New truncatePath util middle-truncates long paths so the file
name (the most useful tail) stays visible:
'packages/.../basic-react/src/design-issues.tsx:128'
→ '…tests/fixtures/basic-react/src/design-issues.tsx:128'
- SourceSnippet takes a maxLineWidth budget and truncates content
with '…' instead of wrapping past the pane.
- Same treatment on the dashboard's FocusedIssue and
CompactIssueList: every text element gets a width budget so the
whole frame fits inside terminalColumns.
Tests:
- truncate-text.test.ts: budget edge cases (already-fits, exact
fit, exceeds, width=1, non-positive) for both truncateText and
truncatePath.
- narrow-screen.test.tsx: regression cases that reproduce the
reported screenshot — at 80 cols the review panes stack, no line
exceeds the terminal width, long rule keys get an ellipsis, and
the 'no-disabled-z'/'om' wrap-glitch can't reappear. Dashboard at
70 cols is also bounded.
(676 tests passing; previously 663.)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…rendering The 91-col snapshot reproduces the exact terminal width from the 'looks cooked' screenshot. With the truncation fix in place, every line fits the terminal width, rule keys end with an ellipsis, file paths are middle-truncated, and the source snippet's content ellipsizes instead of wrapping past the pane boundary. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Previously the only TUI guard was a TTY check, so an Ink render
would still start inside coding-agent shells (Cursor, Claude Code,
OpenCode, Codex, Amp) or on CI runners as long as something was
piping a pseudo-TTY. The result was a frozen, character-soup
buffer because the agent's stdin can't deliver keystrokes.
Hardens the guard:
- Extracts NON_INTERACTIVE_ENVIRONMENT_VARIABLES out of cli.ts into
src/utils/is-non-interactive-environment.ts so the standard
CLI's prompt-skip and the TUI's start-refuse share one source
of truth. cli.ts now imports the helper instead of redeclaring
the list (zero behaviour change for non-TUI scans).
- runTui calls a new checkTuiPreflight() that returns an
{ ok, reason, hint } record. It refuses to start when:
1. process.stdout.isTTY is false, or
2. process.stdin.isTTY is false, or
3. any of CI, GITHUB_ACTIONS, GITLAB_CI, BUILDKITE,
JENKINS_URL, TF_BUILD, CODEBUILD_BUILD_ID, TEAMCITY_VERSION,
BITBUCKET_BUILD_NUMBER, CIRCLECI, TRAVIS, DRONE, CLAUDECODE,
CLAUDE_CODE, CURSOR_AGENT, CODEX_CI, OPENCODE, or AMP_HOME
is set in the environment.
The error message names the triggering env var so users debugging
a 'why won't the TUI start?' issue get an actionable hint, and
always points at `react-doctor` (and `--json`) as the
non-interactive replacement.
- The `react-doctor tui --help` after-text documents both the
TTY / agent-env guard and the color-detection contract: Ink
uses chalk 5 internally, which respects NO_COLOR, FORCE_COLOR,
TERM, COLORTERM, and CI for terminal-color autodetection — so
no extra flags are needed.
Tests:
- preflight.test.ts: clean env passes; missing stdout TTY fails;
missing stdin TTY fails; every documented agent / CI env var
triggers the refusal and is named in the reason; the hint
always mentions `react-doctor` as the fallback.
Verified end-to-end: with CURSOR_AGENT=1 set in this run's env,
`node bin/react-doctor.js tui` prints
'agent / CI environment detected (CURSOR_AGENT is set)' and exits
non-zero instead of starting Ink.
(690 tests passing; previously 676.)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
| const formatProjectPath = (rootDirectory: string): string => { | ||
| const homeDirectory = os.homedir(); | ||
| if (homeDirectory && rootDirectory.startsWith(homeDirectory)) { | ||
| return path.join("~", rootDirectory.slice(homeDirectory.length)); |
There was a problem hiding this comment.
Home directory prefix match lacks path boundary check
Low Severity
formatProjectPath uses rootDirectory.startsWith(homeDirectory) without verifying a path-separator boundary. If the home directory is e.g. /home/user and the project is at /home/username/project, the match succeeds and the path renders as ~/name/project instead of the full absolute path. Adding a trailing separator to the startsWith check (or also checking equality) would prevent the false prefix match.
Reviewed by Cursor Bugbot for commit 077c63f. Configure here.
Surfaces an at-a-glance view of where issues are concentrated
across the codebase, without bringing back the bordered tile from
the earlier health-app design. The chart sits between the score
gauge and the focused issue:
82 / 100 Great ▲ 4
███████████████████████░░░░░
By category
Performance ██████████████ 5
State Effects ██████░░░░░░░░ 2 2✗
Accessibility █████░░░░░░░░░ 1
✗ react-doctor/no-fetch-in-effect 2 sites
...
The component is borderless (just a 'By category' hint and one
row per category, no boxes), bars are proportional to the highest
total, and a small red 'N✗' badge appears only on rows that
actually contain errors so the eye can find the worst offender
fast.
Hidden when:
- there's only 1 category (CATEGORY_BREAKDOWN_MIN_CATEGORIES = 2);
the focused issue and compact list already convey the same info
- the very first scan is in flight (no diagnostics yet)
- the scan errored
Width-responsive: category-name column shrinks to fit, bar width
clamps between CATEGORY_BAR_MIN_WIDTH_CHARS (6) and
CATEGORY_BAR_MAX_WIDTH_CHARS (14), and rows are truncated to
contentWidth so they never overflow on narrow terminals.
Tests:
- category-breakdown.test.tsx (computeCategoryBreakdown + the Ink
component): empty input, severity counted separately, sorted
by total desc, missing category falls back to 'uncategorized',
proportional bar widths, error badge gating, '+ N more' overflow,
width-bounded rendering at 40 cols, empty render returns ''.
- dashboard-view.test.tsx: chart appears above the focused issue,
hidden when only 1 category exists, hidden during the first
scan.
(703 tests passing; previously 690.)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…rrow nav
Three asks rolled into one commit:
1. [c] copy the focused / selected issue as agent-pasteable markdown
- new src/tui/utils/format-issue-as-markdown.ts produces a
self-contained block: rule id + severity + site count, message,
suggestion, list of every site (with overflow), the source
snippet of the first site, and a closing imperative
('Please fix all N occurrences of `<rule>`.') so an agent
can act on it directly.
- new src/tui/utils/copy-to-clipboard.ts shells out to the
platform-appropriate command (pbcopy on darwin; clip on
win32; wl-copy → xclip → xsel on linux). When no clipboard
is available it falls back to writing /tmp/react-doctor-issue-<ts>.md
so the user still has something to paste. Each helper is
fully injectable for tests (spawnImpl, platform, tmpDirImpl,
writeFileImpl).
- App.tsx: handleCopy picks the focused rule on the dashboard
and the cursor-selected rule in review mode, then surfaces
the result through a 3-tone Toast that auto-dismisses after
TOAST_AUTO_DISMISS_MS (2.5s). Toast nonce + tone live in the
reducer so concurrent toasts don't collide.
2. Esc closes the help overlay
- useInput now special-cases helpVisible: only esc / ? / q /
ctrl-c are accepted while help is up, every other key is
swallowed so the user can't accidentally trigger an action
while reading. HelpOverlay's footer now reads
'Press esc or ? to dismiss.'
3. Arrow up / down nav verified end-to-end
- new keyboard-nav.test.tsx uses ink-testing-library's stdin
to assert that real arrow-key escape sequences ('\u001B[A',
'\u001B[B', '\u001B[C', '\u001B[D') drive the right
reducer transitions in review mode and the project picker,
plus that j/k mirror up/down, that enter selects the picker
row, and that esc / ? close the help overlay. The test waits
50ms after each keystroke because Ink's keypress dispatch
batches via useEffectEvent and a 5ms wait sometimes fired
before re-render.
Other touch-ups:
- StatusBar advertises [c] copy on both dashboard and review
- HelpOverlay documents [c] and now lists 'esc' as 'exit filter
/ close help / back to dashboard' to reflect the new behaviour.
Tests: 721 passing (was 703).
- format-issue-as-markdown.test.ts: 6 cases (header, pluralisation,
message + suggestion, relative paths, '… + N more' overflow,
closing imperative).
- copy-to-clipboard.test.ts: 5 cases (darwin pbcopy, win32 clip,
linux fall-through wl-copy → xclip → xsel, tmp-file fallback,
hard error when both clipboard and tmp-write fail).
- keyboard-nav.test.tsx: 6 cases across review (up/down/left/right
+ j/k aliases) and the picker (down then up, enter selects),
plus 2 for esc / ? closing help.
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…path The standard CLI prints a 'Share your results: <url>' line and a 'Full diagnostics written to <tmp dir>' line at the end of every scan, plus a 'React Doctor (www.react.doctor)' branding hint. The TUI ran in silent mode and dropped all of that. Users had no easy way to share results, no on-disk artifact, and no way to find the home / GitHub URLs. Surfaces all three across the dashboard chrome: - Header now justifies between project path on the left and 'react.doctor' on the right. Hidden when terminal width drops below VERY_NARROW_LAYOUT_BREAKPOINT_COLS so paths still fit. - ScanSummaryFooter gains two extra dim lines after every scan completes: Share your results: https://www.react.doctor/share?p=...&s=82 Full diagnostics written to /tmp/react-doctor-<uuid> - HelpOverlay (?) lists Home and GitHub URLs at the bottom for the moments users hit '?' looking for a link to the docs. Implementation: - New REACT_DOCTOR_HOME_URL constant in src/constants.ts so the string is grepable and shared. - New src/tui/utils/build-share-url.ts and src/tui/utils/write-diagnostics-to-temp-dir.ts utils. Both are pure-ish (TUI version intentionally writes a smaller artifact — diagnostics.json only — instead of the standard CLI's per-rule text files, since the TUI lets the user browse those rules interactively). All filesystem / crypto / tmp deps are injectable for tests. - AppState gains diagnosticsDirectory + shareUrl, with a new set-scan-artifacts action. A useEffect in App watches scanCount via a ref and, on every increment that finishes successfully, rewrites the tmp directory and recomputes the share URL. Scans that produce zero diagnostics clear both fields back to null (nothing to share). - Filesystem failures during artifact write are caught and shown as an error toast instead of crashing the App. Tests (16 new, total 734 passing): - write-diagnostics-to-temp-dir + buildShareUrl unit tests with injected impls (including: zero-count params omitted, missing score omits 's=', tmp path uses the supplied uuid). - set-scan-artifacts reducer transitions: starts null, stores values, can clear back to null. - Header rendering: shows react.doctor at wide widths, hides it on narrow. - ScanSummaryFooter rendering: surfaces both share URL and diagnostics path when present, omits one when null, omits both before the first scan completes. - HelpOverlay surfaces the home + GitHub URLs. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Scan duration in milliseconds isn't actionable — users care about results, not how long the scan took. The standard CLI shows it in its own static output if you want it. The TUI footer now leads with the issue count: before: Last scan 528ms · 554 issues across 147 files · ● watching after: 554 issues across 147 files · ● watching Removes the now-unused format-elapsed util (the standard CLI's internal helper in scan.ts is unaffected). Updates the render-smoke assertion to assert that the footer no longer contains 'Last scan' and now contains 'issue'. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
| } | ||
| if (controllerEvent.type === "finished") { | ||
| isScanInFlightRef.current = false; | ||
| } |
There was a problem hiding this comment.
Scan failure emits duplicate error dispatches to store
Low Severity
When scan() throws, it emits a "failed" ScanEvent via the reporter (which the listener dispatches as scan-event), then re-throws. The scan-controller catch block then fires the listener again with type: "failed", dispatching a second scan-failed action. Both set identical error state in the store. This double-dispatch is unnecessary indirection — one of the two paths is redundant.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 49819bb. Configure here.
Filter implies removing things you don't want; search is what the user is actually doing here (typing a substring to narrow the diagnostic list). Renaming for consistency end-to-end: User-facing strings: - StatusBar review-mode shortcut [/] filter -> [/] search - StatusBar search-mode shortcut [type] filter -> [type] search - SearchInput prompt 'filter ▸' -> 'search ▸' (component renamed from FilterInput) - ReviewView header 'filter:' -> 'search:' - DiagnosticList empty state 'No diagnostics match the current filter' -> 'No diagnostics match the current search' - HelpOverlay '/ filter diagnostics' -> '/ search diagnostics' - HelpOverlay 'exit filter / close help' -> 'exit search / close help' Internal renames (so future contributors don't see filter and search refer to the same thing): - AppState.filterText -> searchText - AppState.isFilterActive -> isSearchActive - AppState.filteredDiagnostics -> matchedDiagnostics - AppAction 'set-filter' -> 'set-search' - AppAction 'toggle-filter' -> 'toggle-search' - src/tui/utils/filter-diagnostics.ts -> search-diagnostics.ts - filterDiagnosticsByText() -> searchDiagnostics() - src/tui/components/filter-input.tsx -> search-input.tsx - FilterInput component -> SearchInput Untouched: array .filter() calls in scan-summary-footer, build-share-url, and inline-progress (those are JS array operations, not user-facing filter UX). Test files updated in lockstep: - filter-diagnostics.test.ts -> search-diagnostics.test.ts (with assertions reworded to use 'search term' instead of 'filter') - All test fixtures swap filteredDiagnostics -> matchedDiagnostics 736 tests passing; format / lint / typecheck / test all green. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Ink's default render path does in-place updates in the terminal's primary buffer using cursor-up + clear-line. That works when the frame size stays roughly the same, but fails when the dashboard grows significantly between consecutive frames — which it does on every scan completion (initial 'scanning' state with no score is short, post-scan dashboard with the focused issue + category chart + footer is much taller). When the second, taller frame is drawn, the redraw region only covers the first frame's height, so the bottom rows of the post-scan dashboard get appended below it instead of replacing in place. The user sees both frames stacked — the screenshot showed the project path, doctor face, and score gauge rendered twice. Switches the TUI to Ink's alternate screen buffer (`alternateScreen: true`), the same mechanism vim, htop, and less use. The TUI now gets a clean separate full-screen view for the duration of the session, every redraw is in-place, and the user's original terminal contents (and scrollback) are restored when the TUI exits. Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…install-skill prompt; revert alt-screen
Bundles four UX changes the user asked for in one batch:
1. Watch mode is always on, no toggle.
- Removes the [w] dashboard shortcut, the 'w' useInput handler,
the toggleWatch callback, and the dropped 'on/off' label.
- The watcher attaches as soon as a workspace is selected and
stays attached until exit. Drops the --watch CLI flag (the
'tui' subcommand description now reads 'rescans
automatically on save').
- Footer drops the '● watching' indicator — there's no off
state to contrast it against.
2. [c] copies the WHOLE payload, not just the focused issue.
- New format-all-diagnostics-as-markdown.ts util that builds
a single markdown report: project header (rule count, site
count, optional score, severity-rule split), an actionable
instruction, and one section per rule produced by
formatIssueAsMarkdown joined with horizontal rules. The
toast now reads 'Copied N rules / M sites to clipboard'.
- Help-overlay description for [c] updated to 'copy every
diagnostic as agent-pasteable markdown'.
3. Narrow-screen layout tightened.
- The 'By category' breakdown (recently added) is hidden when
terminal columns drop below NARROW_LAYOUT_BREAKPOINT_COLS
(90). The focused issue + source snippet is the highest-
value content; the breakdown is the first thing to cut.
- Compact 'next rules' list capped to 3 entries on narrow
terminals (was 5).
4. Reverts the alternateScreen render option (was added last
commit to fix frame-doubling). Per user request, the TUI
renders in the primary buffer like before. Frame-doubling
may reappear on terminals where Ink's incremental redraw
leaves residue when the dashboard grows between frames; the
trade-off is full scrollback access during the session.
Plus the install-skill prompt the user asked for previously:
5. First-run skill-install prompt.
- new src/utils/first-run-state.ts: XDG-aware state file at
$XDG_STATE_HOME/react-doctor/state.json, fall back to
$XDG_CONFIG_HOME/react-doctor and finally
~/.config/react-doctor/state.json. Stores
skillPromptShownAt so the prompt only ever fires once.
- new src/utils/maybe-prompt-install-skill.ts: gates on
interactive TTY + non-CI/agent env + not-yet-shown +
at-least-one-detected-agent. On yes, calls
runInstallSkill({yes:true,detectedAgents}). Either way
records the prompt-shown timestamp so the next invocation
skips silently.
- Wired into the standard CLI's main action (after the
version banner, before the scan, suppressed for --json /
--score / --yes / --full / non-interactive shells) and
into runTui (before Ink takes the TTY, since Ink
conflicts with the prompts library).
Tests:
- format-all-diagnostics-as-markdown.test.ts: 5 cases
(empty-state friendly message, header rule/site totals,
optional score line, per-rule sections joined with horizontal
rules, ends with the formatIssueAsMarkdown action prompt).
- first-run-state.test.ts: 13 cases covering XDG path
resolution, malformed-JSON tolerance, type-narrowed reads,
write success and error, the hasShownSkillPrompt predicate.
(753 tests passing; previously 736.)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dc6cc91. Configure here.
| ) | ||
| .action(async (directory: string, options: TuiSubcommandOptions) => { | ||
| await launchTui(directory, options); | ||
| }); |
There was a problem hiding this comment.
Documented --watch flag missing from CLI command definition
High Severity
The README and PR description document react-doctor tui . --watch as a valid command, but the tui subcommand only registers --review and --project options. The TuiSubcommandOptions interface also lacks a watch field. Since Commander rejects unknown options by default, running react-doctor tui . --watch will produce a CLI error like "unknown option '--watch'". Watch mode is always-on in the app (per the comment in app.tsx), so either the --watch option needs to be registered (even if it's a no-op), or the documentation needs updating.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit dc6cc91. Configure here.


Adds an interactive React code-health terminal UI to
react-doctor, available as atuisubcommand. Built on Ink, with a focused single-screen dashboard, monorepo project picker, watch mode, and a master/detail diagnostic browser.Project picker (monorepo support)
Mirrors the standard CLI's
selectProjectsbehaviour, but as an Ink screen since the TUI owns the TTY. Shown only when 2+ React workspace packages are discovered:listWorkspacePackages→discoverReactSubprojectsfallback from the existing CLI.--project <name|basename>skips the picker for non-interactive flows (e.g. inside an editor task).Dashboard
Answers three questions, in order:
Header is just the project path (home-dir aware:
~/foo/bar). Watching status, scan elapsed time, and offline mode all live in the single-line footer where they belong. The doctor face stays as the cute 4-line ASCII branding next to the score; mood tracks the score.Review screen
Master/detail diagnostic browser with severity-coloured rule list, source-snippet preview, and an inline filter (
/) that searches across rule, plugin, category, message, and file path. Stacks vertically on narrow terminals.How it's wired up
ScanReporterinterface and discriminatedScanEventunion added toreact-doctor/api.scan()emits structured events alongside its existing stdout output. When no reporter is supplied, behaviour is unchanged.src/tui/and ships as a separatevite-plusbuild entry (dist/tui.js). The CLI lazy-loads it on demand so non-TUI scans never touch Ink/React/chokidar.chokidarwatch loop with debounce.useTerminalSizehook subscribes to stdoutresizeevents; layout adapts at 60- and 90-col breakpoints (score bar shrinks, review panes split master/detail vertically).M/Ncounter — not a 10-row checklist.rescanning…to the footer.✓ No issues detected — nice work.Doctor-face fix
The "first 1-2 lines show double" rendering glitch was a width mismatch: the eyes row (
│ ◠ ◠ │= 7 chars) and the mouth row (│ ─ │= 5 chars) had different widths, so Ink's incremental redraw left stale chars from the previous frame on the shorter row. Every frame across every mood is now exactly 5 inner chars; a unit test enforces this. Animation interval slowed from 240ms to 480ms; asetTimeoutleak in the blink scheduler is plugged.Validation
pnpm format— cleanpnpm lint— 0 warnings, 0 errorspnpm typecheck— cleanpnpm test— 662 tests passing across 55 test filespnpm build—dist/tui.jsbuilds alongsidedist/cli.js,dist/index.js, and the lint pluginsOut of scope (follow-ups)
react-doctor-disable-next-linecomment from the review screen.$EDITORjump from the detail pane..react-doctor/history.jsonso the trend survives across runs.