From 0fe26d58d2419edcfd60a99b783fc999ea5bb0d3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 5 Aug 2026 06:09:41 +0000 Subject: [PATCH 1/2] fix(cli): preserve results on cancellation --- .../avoid-duplicate-tui-cancel-footer.md | 2 +- .../react-doctor/src/cli/ink/run-scan-app.tsx | 43 ++++++++----------- .../src/cli/utils/active-tui-renderer.ts | 6 +-- .../src/cli/utils/exit-gracefully.ts | 4 +- .../tests/active-tui-renderer.test.ts | 22 +++++----- .../tests/ink/exit-on-ctrl-c.test.tsx | 4 +- .../tests/ink/run-scan-app.test.ts | 24 ++++++++--- 7 files changed, 57 insertions(+), 48 deletions(-) diff --git a/.changeset/avoid-duplicate-tui-cancel-footer.md b/.changeset/avoid-duplicate-tui-cancel-footer.md index d15d08f27..f420ae06d 100644 --- a/.changeset/avoid-duplicate-tui-cancel-footer.md +++ b/.changeset/avoid-duplicate-tui-cancel-footer.md @@ -2,4 +2,4 @@ "react-doctor": patch --- -Prevent Ctrl-C from printing the completed-scan footer after the interactive cancellation footer. +Keep the final interactive results in terminal history when Ctrl-C prints the cancellation footer, without printing a second completed-scan footer. 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 a486ca8b2..1d626277b 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -183,6 +183,23 @@ const resolveSelectedDirectories = async ( return promptProjectSelection(packages, rootDirectory); }; +const registerMountedTuiRenderer = (instance: ReturnType): (() => void) => { + let didDisposeRenderer = false; + const disposeRenderer = (shouldClearOutput: boolean): void => { + if (didDisposeRenderer) return; + didDisposeRenderer = true; + if (shouldClearOutput) instance.clear(); + instance.unmount(); + }; + const unregisterActiveTuiRenderer = registerActiveTuiRenderer({ + preserveOutput: () => disposeRenderer(false), + }); + return () => { + unregisterActiveTuiRenderer(); + disposeRenderer(true); + }; +}; + const promptProjectSelection = ( packages: ReadonlyArray, rootDirectory: string, @@ -201,18 +218,7 @@ const promptProjectSelection = ( />, { alternateScreen: false, exitOnCtrlC: false }, ); - let didClearRenderer = false; - const clearRenderer = (): void => { - if (didClearRenderer) return; - didClearRenderer = true; - instance.clear(); - instance.unmount(); - }; - const unregisterActiveTuiRenderer = registerActiveTuiRenderer({ clear: clearRenderer }); - disposeRenderer = () => { - unregisterActiveTuiRenderer(); - clearRenderer(); - }; + disposeRenderer = registerMountedTuiRenderer(instance); }); interface ScanReportInput { @@ -394,20 +400,9 @@ const mountScanApp = async ( />, { alternateScreen: false, exitOnCtrlC: false }, ); - let didClearRenderer = false; - const clearRenderer = (): void => { - if (didClearRenderer) return; - didClearRenderer = true; - instance.clear(); - instance.unmount(); - }; - const unregisterActiveTuiRenderer = registerActiveTuiRenderer({ clear: clearRenderer }); return { instance, - dispose: () => { - unregisterActiveTuiRenderer(); - clearRenderer(); - }, + dispose: registerMountedTuiRenderer(instance), }; }; const executePendingActions = async (): Promise => { diff --git a/packages/react-doctor/src/cli/utils/active-tui-renderer.ts b/packages/react-doctor/src/cli/utils/active-tui-renderer.ts index c2f0eebfe..22c54dc62 100644 --- a/packages/react-doctor/src/cli/utils/active-tui-renderer.ts +++ b/packages/react-doctor/src/cli/utils/active-tui-renderer.ts @@ -1,5 +1,5 @@ export interface ActiveTuiRenderer { - readonly clear: () => void; + readonly preserveOutput: () => void; } let activeTuiRenderer: ActiveTuiRenderer | null = null; @@ -11,8 +11,8 @@ export const registerActiveTuiRenderer = (renderer: ActiveTuiRenderer): (() => v }; }; -export const clearActiveTuiRenderer = (): void => { +export const preserveActiveTuiRendererOutput = (): void => { const renderer = activeTuiRenderer; activeTuiRenderer = null; - renderer?.clear(); + renderer?.preserveOutput(); }; diff --git a/packages/react-doctor/src/cli/utils/exit-gracefully.ts b/packages/react-doctor/src/cli/utils/exit-gracefully.ts index 5d7cd35ab..bf73c6dde 100644 --- a/packages/react-doctor/src/cli/utils/exit-gracefully.ts +++ b/packages/react-doctor/src/cli/utils/exit-gracefully.ts @@ -1,6 +1,6 @@ import { flushSentry } from "../../instrument.js"; import { activeScanAbortRegistry } from "./active-scan-abort-registry.js"; -import { clearActiveTuiRenderer } from "./active-tui-renderer.js"; +import { preserveActiveTuiRendererOutput } from "./active-tui-renderer.js"; import { buildFooterLinkLines } from "./build-footer-link-lines.js"; import { buildSectionDivider } from "./build-section-divider.js"; import { SIGINT_EXIT_CODE } from "./constants.js"; @@ -14,7 +14,7 @@ export const exitGracefully = (): void => { if (didStartExiting) return process.exit(SIGINT_EXIT_CODE); didStartExiting = true; activeScanAbortRegistry.abortAll(); - clearActiveTuiRenderer(); + preserveActiveTuiRendererOutput(); try { if (isJsonModeActive()) { writeJsonErrorReport(new Error("Scan cancelled by user (SIGINT/SIGTERM)")); diff --git a/packages/react-doctor/tests/active-tui-renderer.test.ts b/packages/react-doctor/tests/active-tui-renderer.test.ts index 9eaf97a3c..c3840bba1 100644 --- a/packages/react-doctor/tests/active-tui-renderer.test.ts +++ b/packages/react-doctor/tests/active-tui-renderer.test.ts @@ -1,21 +1,23 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { - clearActiveTuiRenderer, + preserveActiveTuiRendererOutput, registerActiveTuiRenderer, } from "../src/cli/utils/active-tui-renderer.js"; describe("activeTuiRenderer", () => { - it("clears only the currently registered renderer", () => { - const firstClear = vi.fn(); - const secondClear = vi.fn(); - const unregisterFirst = registerActiveTuiRenderer({ clear: firstClear }); - registerActiveTuiRenderer({ clear: secondClear }); + it("preserves only the currently registered renderer", () => { + const firstPreserveOutput = vi.fn(); + const secondPreserveOutput = vi.fn(); + const unregisterFirst = registerActiveTuiRenderer({ + preserveOutput: firstPreserveOutput, + }); + registerActiveTuiRenderer({ preserveOutput: secondPreserveOutput }); unregisterFirst(); - clearActiveTuiRenderer(); - clearActiveTuiRenderer(); + preserveActiveTuiRendererOutput(); + preserveActiveTuiRendererOutput(); - expect(firstClear).not.toHaveBeenCalled(); - expect(secondClear).toHaveBeenCalledTimes(1); + expect(firstPreserveOutput).not.toHaveBeenCalled(); + expect(secondPreserveOutput).toHaveBeenCalledTimes(1); }); }); 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 index 73de8ec38..42c73f73a 100644 --- a/packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx +++ b/packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx @@ -24,7 +24,7 @@ describe("useExitOnCtrlC", () => { lifecycleEvents.push("log"); }); registerActiveTuiRenderer({ - clear: () => lifecycleEvents.push("clear"), + preserveOutput: () => lifecycleEvents.push("preserve"), }); const { stdin, unmount } = render(); @@ -36,7 +36,7 @@ describe("useExitOnCtrlC", () => { expect(exitSpy).toHaveBeenCalledWith(130); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(DOCS_URL)); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(CANONICAL_GITHUB_URL)); - expect(lifecycleEvents.slice(0, 2)).toEqual(["clear", "log"]); + expect(lifecycleEvents.slice(0, 2)).toEqual(["preserve", "log"]); unmount(); }); diff --git a/packages/react-doctor/tests/ink/run-scan-app.test.ts b/packages/react-doctor/tests/ink/run-scan-app.test.ts index 267416667..67acd671f 100644 --- a/packages/react-doctor/tests/ink/run-scan-app.test.ts +++ b/packages/react-doctor/tests/ink/run-scan-app.test.ts @@ -1,6 +1,7 @@ import * as path from "node:path"; import * as Effect from "effect/Effect"; import { render } from "ink"; +import { isValidElement } from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { DiffInfo, @@ -11,7 +12,7 @@ import type { import { getBaselineDiffPlan, getDiffInfo, Reporter, resolveScanTarget } from "@react-doctor/core"; import { runScanApp } from "../../src/cli/ink/run-scan-app.js"; import type { ScanStore, TuiHandoffRequest } from "../../src/cli/ink/scan-store.js"; -import { clearActiveTuiRenderer } from "../../src/cli/utils/active-tui-renderer.js"; +import { preserveActiveTuiRendererOutput } from "../../src/cli/utils/active-tui-renderer.js"; import { computeProjectedScore } from "../../src/cli/utils/compute-score-projection.js"; import { inspect } from "../../src/inspect.js"; import { buildDiagnostic, buildTestProject } from "../regressions/_helpers.js"; @@ -38,6 +39,7 @@ const mockState = vi.hoisted(() => ({ shouldRequestHandoff: false, shouldSetUpCi: false, shouldQuit: false, + shouldAutoSubmitProjectSelection: true, scanRendererClearCount: 0, lifecycleEvents: new Array(), scanStores: new Array(), @@ -51,7 +53,11 @@ vi.mock("ink", async (importOriginal) => { return { ...actual, render: vi.fn((node) => { - if (React.isValidElement(node) && node.props.packages) { + if ( + React.isValidElement(node) && + node.props.packages && + mockState.shouldAutoSubmitProjectSelection + ) { queueMicrotask(() => node.props.onSubmit?.(mockState.projectDirectories)); } if (React.isValidElement(node)) { @@ -196,6 +202,7 @@ describe("runScanApp", () => { mockState.shouldRequestHandoff = false; mockState.shouldSetUpCi = false; mockState.shouldQuit = false; + mockState.shouldAutoSubmitProjectSelection = true; mockState.scanRendererClearCount = 0; mockState.lifecycleEvents.length = 0; mockState.scanStores.length = 0; @@ -235,7 +242,7 @@ describe("runScanApp", () => { }); }); - it("clears the project selection screen through the active renderer lifecycle", async () => { + it("preserves the active screen in scrollback when exiting", async () => { vi.spyOn(process.stdout, "write").mockImplementation(() => true); const rootDirectory = "/repo"; const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -244,6 +251,7 @@ describe("runScanApp", () => { { name: "web", directory: "/repo/apps/web" }, { name: "admin", directory: "/repo/apps/admin" }, ); + mockState.shouldAutoSubmitProjectSelection = false; mockState.scanTargets.set( rootDirectory, buildScanTarget(rootDirectory, rootDirectory, null, rootDirectory), @@ -254,12 +262,16 @@ describe("runScanApp", () => { await vi.waitFor(() => expect(render).toHaveBeenCalled()); const selectionRenderer = vi.mocked(render).mock.results[0]?.value; - clearActiveTuiRenderer(); + preserveActiveTuiRendererOutput(); - expect(selectionRenderer?.clear).toHaveBeenCalledOnce(); + expect(selectionRenderer?.clear).not.toHaveBeenCalled(); expect(selectionRenderer?.unmount).toHaveBeenCalledOnce(); + const selectionNode = vi.mocked(render).mock.calls[0]?.[0]; + if (isValidElement(selectionNode)) { + selectionNode.props.onSubmit?.([]); + } await scanPromise; - expect(selectionRenderer?.clear).toHaveBeenCalledOnce(); + expect(selectionRenderer?.clear).not.toHaveBeenCalled(); expect(selectionRenderer?.unmount).toHaveBeenCalledOnce(); } finally { if (originalIsTtyDescriptor) { From 1fab119dd8816f4b6377f268ee59b1d52950b64a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 5 Aug 2026 07:03:03 +0000 Subject: [PATCH 2/2] fix(cli): discard project picker after selection --- .changeset/avoid-duplicate-tui-cancel-footer.md | 2 +- packages/react-doctor/src/cli/ink/run-scan-app.tsx | 4 ++-- packages/react-doctor/src/cli/utils/constants.ts | 2 +- packages/react-doctor/tests/ink/run-scan-app.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/avoid-duplicate-tui-cancel-footer.md b/.changeset/avoid-duplicate-tui-cancel-footer.md index f420ae06d..26ac61c82 100644 --- a/.changeset/avoid-duplicate-tui-cancel-footer.md +++ b/.changeset/avoid-duplicate-tui-cancel-footer.md @@ -2,4 +2,4 @@ "react-doctor": patch --- -Keep the final interactive results in terminal history when Ctrl-C prints the cancellation footer, without printing a second completed-scan footer. +Keep transient project selection out of terminal history, and preserve final interactive results when Ctrl-C prints the cancellation footer without printing a second completed-scan footer. 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 1d626277b..78470131b 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -206,7 +206,7 @@ const promptProjectSelection = ( ): Promise => new Promise((resolve) => { let disposeRenderer = (): void => {}; - recordCount(METRIC.tuiProjectSelectInlineShown); + recordCount(METRIC.tuiProjectSelectShown); const instance = render( , - { alternateScreen: false, exitOnCtrlC: false }, + { alternateScreen: true, exitOnCtrlC: false }, ); disposeRenderer = registerMountedTuiRenderer(instance); }); diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index b5269e448..cf7b43586 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -306,7 +306,7 @@ export const METRIC = { tuiFindingNavigated: "tui.finding_navigated", tuiIssueStreamShown: "tui.issue_stream_shown", tuiProjectPathContextShown: "tui.project_path_context_shown", - tuiProjectSelectInlineShown: "tui.project_select_inline_shown", + tuiProjectSelectShown: "tui.project_select_shown", tuiReportActionSelected: "tui.report_action_selected", tuiCancelled: "tui.cancelled", tuiScanInlineShown: "tui.scan_inline_shown", diff --git a/packages/react-doctor/tests/ink/run-scan-app.test.ts b/packages/react-doctor/tests/ink/run-scan-app.test.ts index 67acd671f..690f9df27 100644 --- a/packages/react-doctor/tests/ink/run-scan-app.test.ts +++ b/packages/react-doctor/tests/ink/run-scan-app.test.ts @@ -212,7 +212,7 @@ describe("runScanApp", () => { vi.clearAllMocks(); }); - it("keeps terminal scrollback visible during project selection", async () => { + it("uses a disposable screen for project selection", async () => { vi.spyOn(process.stdout, "write").mockImplementation(() => true); const rootDirectory = "/repo"; const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -237,7 +237,7 @@ describe("runScanApp", () => { } expect(vi.mocked(render).mock.calls[0]?.[1]).toEqual({ - alternateScreen: false, + alternateScreen: true, exitOnCtrlC: false, }); });