Skip to content

feat(react-doctor): interactive code-health TUI as a tui subcommand#173

Open
aidenybai wants to merge 23 commits into
mainfrom
cursor/react-doctor-tui-50e9
Open

feat(react-doctor): interactive code-health TUI as a tui subcommand#173
aidenybai wants to merge 23 commits into
mainfrom
cursor/react-doctor-tui-50e9

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented May 8, 2026

Adds an interactive React code-health terminal UI to react-doctor, available as a tui subcommand. Built on Ink, with a focused single-screen dashboard, monorepo project picker, watch mode, and a master/detail diagnostic browser.

react-doctor tui .                       # picker if monorepo, else dashboard
react-doctor tui . --watch               # rescan on file changes
react-doctor tui . --review              # open straight into the diagnostic browser
react-doctor tui . --project ami         # preselect a workspace package

Project picker (monorepo support)

Mirrors the standard CLI's selectProjects behaviour, but as an Ink screen since the TUI owns the TTY. Shown only when 2+ React workspace packages are discovered:

~/projects/ami

  Multiple React projects found. Pick one to scan:

    ami    packages/ami
  ▸ admin  packages/admin
    docs   packages/docs

  [↑↓] move  [↵] scan this project  [q] quit
  • Reuses listWorkspacePackagesdiscoverReactSubprojects fallback from the existing CLI.
  • 0 packages → scan the root directly. 1 package → auto-select. 2+ → picker.
  • --project <name|basename> skips the picker for non-interactive flows (e.g. inside an editor task).

Dashboard

Answers three questions, in order:

  1. How healthy is the codebase? → score gauge with delta vs. last scan
  2. What's most broken? → focused issue with rule, message, help, file:line, and a source-snippet preview
  3. What else? → compact list of remaining rules with site counts
~/projects/ami

  ┌─────┐  82 / 100 Great  ▲ 4
  │ ◠ ◠ │  █████████████████████████░░░░░
  │  ▽  │
  └─────┘

  ✗ react-doctor/no-fetch-in-effect  2 sites
    Avoid fetch inside useEffect.
    → Use a data-fetching library like TanStack Query.

      src/UserCard.tsx:42
       40│   const [user, setUser] = useState(null);
       41│   useEffect(() => {
     ▸ 42│     fetch(`/api/${id}`).then(r => r.json()).then(setUser);
       43│   }, [id]);

      + 1 more site

  ⚠ no-array-index-as-key           5 sites

  Last scan 2.3s · 7 issues across 6 files · ● watching

  [d] review  [r] rescan  [w] watch off  [?] help  [q] quit

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

  • ScanReporter interface and discriminated ScanEvent union added to react-doctor/api. scan() emits structured events alongside its existing stdout output. When no reporter is supplied, behaviour is unchanged.
  • The TUI lives in-package at src/tui/ and ships as a separate vite-plus build entry (dist/tui.js). The CLI lazy-loads it on demand so non-TUI scans never touch Ink/React/chokidar.
  • chokidar watch loop with debounce.
  • useTerminalSize hook subscribes to stdout resize events; layout adapts at 60- and 90-col breakpoints (score bar shrinks, review panes split master/detail vertically).
  • TTY guard with a clear fallback message for non-interactive shells.
  • During scanning, a single inline spinner with active step + M/N counter — not a 10-row checklist.
  • Re-scans keep the dashboard visible and append rescanning… to the footer.
  • Errored scans render a red one-line banner above the score gauge with a recovery hint.
  • Empty scans show ✓ 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; a setTimeout leak in the blink scheduler is plugged.

Validation

  • pnpm format — clean
  • pnpm lint — 0 warnings, 0 errors
  • pnpm typecheck — clean
  • pnpm test662 tests passing across 55 test files
  • pnpm builddist/tui.js builds alongside dist/cli.js, dist/index.js, and the lint plugins

Out of scope (follow-ups)

  • Apply-suppression action that writes a react-doctor-disable-next-line comment from the review screen.
  • Open-in-$EDITOR jump from the detail pane.
  • Multi-project scan in a single TUI session (currently one project per session).
  • Persisting score history to .react-doctor/history.json so the trend survives across runs.
Open in Web Open in Cursor 

cursor-agent and others added 3 commits May 8, 2026 10:16
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 8, 2026 1:03pm

@aidenybai aidenybai marked this pull request as ready for review May 8, 2026 10:49
Comment thread packages/react-doctor/src/tui/store.ts
Comment thread packages/react-doctor/src/tui/components/doctor-face.tsx
Comment thread packages/react-doctor-tui/src/components/doctor-face.tsx Outdated
…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>
@cursor cursor Bot changed the title feat: interactive Ink-based code-health TUI for react-doctor feat(react-doctor): interactive code-health TUI as a tui subcommand May 8, 2026
Comment thread packages/react-doctor/src/tui/components/dashboard-view.tsx Outdated
Comment thread packages/react-doctor/src/tui/components/header.tsx
Comment thread packages/react-doctor/src/tui/components/status-bar.tsx Outdated
score: event.result.score.score,
diagnosticCount: event.result.diagnostics.length,
timestamp: Date.now(),
}
Copy link
Copy Markdown

@vercel vercel Bot May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score delta badge never displays because previousScore is set after score-resolved already updated state.score to the new value

Fix on Vercel

NisargIO added 2 commits May 8, 2026 04:14
…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.
cursor-agent and others added 3 commits May 8, 2026 11:19
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>
cursoragent and others added 3 commits May 8, 2026 11:23
…nostics directory creation"

This reverts commit 2d98d6c.
…lionco/react-doctor into cursor/react-doctor-tui-50e9

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Comment thread packages/react-doctor/src/tui/app.tsx Outdated
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>
Comment thread packages/react-doctor/src/tui/components/status-bar.tsx Outdated
const sitesCount = rule.diagnostics.length;
const firstSite = rule.diagnostics[0];
const snippet =
firstSite && firstSite.line > 0 ? readSourceSnippet(firstSite.filePath, firstSite.line) : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 077c63f. Configure here.

Comment thread packages/react-doctor/src/scan.ts
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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>
Comment thread packages/react-doctor/src/scan.ts
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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

❌ 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);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc6cc91. Configure here.

Copy link
Copy Markdown

@JW-Rami JW-Rami left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[deleted]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants