diff --git a/src/commands/analysis/index.ts b/src/commands/analysis/index.ts index d11ec85..2ca50f5 100644 --- a/src/commands/analysis/index.ts +++ b/src/commands/analysis/index.ts @@ -61,10 +61,16 @@ Example: .option("--tsnd", "run with ts-node-dev if installed globally") .option("--deno", "Force build for Deno runtime", false) .option("--node", "Force build for Node.js runtime", false) + .option("--no-interactive", "disable single-key shortcuts (q/h/r/c); shortcuts default on when stdin is a TTY") .action(runAnalysis) .addHelpText( "after", ` + Shortcuts (when stdin is a TTY, opt out with --no-interactive): + r restart the analysis + c clear the screen + h, ? show shortcut help + q, Ctrl-C quit (press Ctrl-C twice within 2s to force quit) Example: $ tagoio run dashboard-handler @@ -75,6 +81,7 @@ Example: $ tagoio run dashboard-handler --node $ tagoio run --deno $ tagoio run --node + $ tagoio run dashboard-handler --no-interactive # CI / piped logs `, ); diff --git a/src/commands/analysis/run-analysis.test.ts b/src/commands/analysis/run-analysis.test.ts index 00fb4d1..62583d1 100644 --- a/src/commands/analysis/run-analysis.test.ts +++ b/src/commands/analysis/run-analysis.test.ts @@ -1,12 +1,35 @@ -import { beforeEach, describe, expect, it, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; const getEnvironmentConfigMock = vi.fn(); const errorHandlerMock = vi.fn((str: unknown) => { throw new Error(String(str)); }); -const spawnMock = vi.fn(() => ({ - on: vi.fn(), -})); +// Spawn mock auto-fires the registered `once("close")` handler on the next +// microtask, so runAnalysis's `await new Promise(close => …)` resolves and the +// respawn loop terminates instead of hanging the test. Set `autoCloseSpawned` +// to `false` when a test wants to drive close timing manually (e.g. to call +// onRestart before the child resolves and assert a second spawn). +let autoCloseSpawned = true; +const spawnedChildren: { kill: ReturnType; close: () => void }[] = []; +const spawnMock = vi.fn((_cmd: string, _opts: object) => { + let closeFn: ((code: number) => void) | undefined; + const child = { + on: vi.fn(), + once: vi.fn((event: string, fn: (code: number) => void) => { + if (event === "close") { + closeFn = fn; + if (autoCloseSpawned) { + queueMicrotask(() => fn(0)); + } + } + }), + kill: vi.fn(), + close: () => closeFn?.(0), + }; + spawnedChildren.push(child); + return child; +}); +const installWatchShortcutsMock = vi.fn((_handlers: unknown, _options: unknown) => () => {}); const pickAnalysisFromConfigMock = vi.fn(); const detectRuntimeMock = vi.fn(() => "--node"); const accountAnalysisInfoMock = vi.fn(); @@ -24,7 +47,7 @@ vi.mock("@tago-io/sdk", () => ({ })); vi.mock("node:child_process", () => ({ - spawn: (...args: unknown[]) => spawnMock(...(args as [])), + spawn: (...args: unknown[]) => spawnMock(...(args as [string, object])), })); vi.mock("../../lib/config-file.js", () => ({ @@ -49,6 +72,7 @@ vi.mock("../../lib/resolve-scope.js", () => ({ vi.mock("../../lib/messages.js", () => ({ errorHandler: errorHandlerMock, successMSG: vi.fn(), + infoMSG: vi.fn(), highlightMSG: (s: string) => s, })); @@ -60,6 +84,10 @@ vi.mock("../../prompt/pick-analysis-from-config.js", () => ({ pickAnalysisFromConfig: (...args: unknown[]) => pickAnalysisFromConfigMock(...args), })); +vi.mock("../../lib/watch-shortcuts.js", () => ({ + installWatchShortcuts: (...args: unknown[]) => installWatchShortcutsMock(...(args as [unknown, unknown])), +})); + describe("buildCMD", () => { let _buildCMD: (options: { tsnd: boolean; debug: boolean; clear: boolean }, runtime: string) => string; beforeEach(async () => { @@ -139,9 +167,20 @@ describe("buildCMD", () => { }); describe("runAnalysis", () => { + let originalIsTTY: boolean | undefined; + beforeEach(() => { vi.clearAllMocks(); + spawnedChildren.length = 0; + autoCloseSpawned = true; + installWatchShortcutsMock.mockImplementation((_h, _o) => () => {}); accountAnalysisEditMock.mockResolvedValue(undefined); + originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsTTY }); }); test("errors out when the environment is missing", async () => { @@ -293,4 +332,169 @@ describe("runAnalysis", () => { }); expect(pickAnalysisFromConfigMock).toHaveBeenCalled(); }); + + test("pressing onRestart kills the current child and respawns with the same command", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + autoCloseSpawned = false; + getEnvironmentConfigMock.mockReturnValue({ + profileToken: "tok", + profileRegion: "usa-1", + analysisList: [{ id: "a1", name: "A", fileName: "a.js" }], + analysisPath: "/tmp", + }); + accountAnalysisInfoMock.mockResolvedValue({ token: "at", run_on: "external", name: "A", runtime: "node" }); + + let capturedHandlers: { onRestart: () => void; onQuit: () => void } | undefined; + installWatchShortcutsMock.mockImplementation((handlers: unknown) => { + capturedHandlers = handlers as { onRestart: () => void; onQuit: () => void }; + return () => {}; + }); + + const { runAnalysis } = await import("./run-analysis.js"); + const promise = runAnalysis("A", { + environment: "prod", + debug: false, + clear: false, + tsnd: false, + deno: false, + node: true, + interactive: true, + } as never); + + // Yield to let runAnalysis register the close handler on child #1. + await new Promise((r) => setImmediate(r)); + expect(spawnedChildren).toHaveLength(1); + + capturedHandlers?.onRestart(); + spawnedChildren[0].close(); + + // Yield to let the loop spawn child #2. + await new Promise((r) => setImmediate(r)); + expect(spawnedChildren).toHaveLength(2); + expect(spawnedChildren[0].kill).toHaveBeenCalledWith("SIGTERM"); + + // Quit to terminate the loop. + capturedHandlers?.onQuit(); + spawnedChildren[1].close(); + await promise; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0][0]).toEqual(spawnMock.mock.calls[1][0]); + }); + + test("pressing onQuit exits the loop and flips run_on back to tago exactly once", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + autoCloseSpawned = false; + getEnvironmentConfigMock.mockReturnValue({ + profileToken: "tok", + profileRegion: "usa-1", + analysisList: [{ id: "a1", name: "A", fileName: "a.js" }], + analysisPath: "/tmp", + }); + accountAnalysisInfoMock.mockResolvedValue({ token: "at", run_on: "external", name: "A", runtime: "node" }); + + let capturedHandlers: { onQuit: () => void } | undefined; + installWatchShortcutsMock.mockImplementation((handlers: unknown) => { + capturedHandlers = handlers as { onQuit: () => void }; + return () => {}; + }); + + const { runAnalysis } = await import("./run-analysis.js"); + const promise = runAnalysis("A", { + environment: "prod", + debug: false, + clear: false, + tsnd: false, + deno: false, + node: true, + interactive: true, + } as never); + + await new Promise((r) => setImmediate(r)); + capturedHandlers?.onQuit(); + spawnedChildren[0].close(); + await promise; + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnedChildren[0].kill).toHaveBeenCalledWith("SIGTERM"); + expect(accountAnalysisEditMock).toHaveBeenCalledWith("a1", { run_on: "tago" }); + const tagoFlips = accountAnalysisEditMock.mock.calls.filter((c) => c[1]?.run_on === "tago"); + expect(tagoFlips).toHaveLength(1); + }); + + test("--no-interactive disables shortcuts (installWatchShortcuts called with enabled: false)", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + getEnvironmentConfigMock.mockReturnValue({ + profileToken: "tok", + profileRegion: "usa-1", + analysisList: [{ id: "a1", name: "A", fileName: "a.js" }], + analysisPath: "/tmp", + }); + accountAnalysisInfoMock.mockResolvedValue({ token: "at", run_on: "external", name: "A", runtime: "node" }); + + const { runAnalysis } = await import("./run-analysis.js"); + await runAnalysis("A", { + environment: "prod", + debug: false, + clear: false, + tsnd: false, + deno: false, + node: true, + interactive: false, + } as never); + + expect(installWatchShortcutsMock).toHaveBeenCalledTimes(1); + const opts = installWatchShortcutsMock.mock.calls[0][1] as { enabled: boolean }; + expect(opts.enabled).toBe(false); + }); + + test("quotes the script path so a path containing spaces stays a single shell argument", async () => { + getEnvironmentConfigMock.mockReturnValue({ + profileToken: "tok", + profileRegion: "usa-1", + analysisList: [{ id: "a1", name: "A", fileName: "a.js" }], + analysisPath: "/Users/maria/My Project", + }); + accountAnalysisInfoMock.mockResolvedValue({ token: "at", run_on: "external", name: "A", runtime: "node" }); + + const { runAnalysis } = await import("./run-analysis.js"); + await runAnalysis("A", { + environment: "prod", + debug: false, + clear: false, + tsnd: false, + deno: false, + node: true, + }); + + const command = spawnMock.mock.calls[0][0]; + // The whole path, spaces included, must sit inside one pair of quotes — + // otherwise the shell would split "/Users/maria/My Project/a.js" into two + // arguments and the runtime would fail to find the file. + expect(command).toContain('"/Users/maria/My Project/a.js"'); + }); + + test("non-TTY stdin disables shortcuts even when --no-interactive is absent", async () => { + // beforeEach already sets isTTY = false; this is the explicit case. + getEnvironmentConfigMock.mockReturnValue({ + profileToken: "tok", + profileRegion: "usa-1", + analysisList: [{ id: "a1", name: "A", fileName: "a.js" }], + analysisPath: "/tmp", + }); + accountAnalysisInfoMock.mockResolvedValue({ token: "at", run_on: "external", name: "A", runtime: "node" }); + + const { runAnalysis } = await import("./run-analysis.js"); + await runAnalysis("A", { + environment: "prod", + debug: false, + clear: false, + tsnd: false, + deno: false, + node: true, + }); + + const opts = installWatchShortcutsMock.mock.calls[0][1] as { enabled: boolean }; + expect(opts.enabled).toBe(false); + }); }); diff --git a/src/commands/analysis/run-analysis.ts b/src/commands/analysis/run-analysis.ts index f4979f7..9f1b1d8 100644 --- a/src/commands/analysis/run-analysis.ts +++ b/src/commands/analysis/run-analysis.ts @@ -1,13 +1,14 @@ -import { SpawnOptions, spawn } from "node:child_process"; +import { ChildProcess, SpawnOptions, spawn } from "node:child_process"; import path from "node:path"; import { Account } from "@tago-io/sdk"; import { getEnvironmentConfig, IEnvironment, resolveCLIPath } from "../../lib/config-file.js"; import { detectRuntime } from "../../lib/current-runtime.js"; -import { errorHandler, highlightMSG, successMSG } from "../../lib/messages.js"; +import { errorHandler, highlightMSG, infoMSG, successMSG } from "../../lib/messages.js"; import { requireLocalScope } from "../../lib/resolve-scope.js"; import { searchName } from "../../lib/search-name.js"; +import { installWatchShortcuts } from "../../lib/watch-shortcuts.js"; import { pickAnalysisFromConfig } from "../../prompt/pick-analysis-from-config.js"; /** @@ -58,16 +59,28 @@ function _buildCMD(options: { tsnd: boolean; debug: boolean; clear: boolean }, r return cmd; } +interface RunAnalysisOptions { + environment: string; + debug: boolean; + clear: boolean; + tsnd: boolean; + deno: boolean; + node: boolean; + /** + * Commander negation flag (`--no-interactive`). Defaults to `true`. When + * `false`, the watch-mode keystroke shortcuts are not installed and the + * command runs exactly as it did before this feature shipped. + */ + interactive?: boolean; +} + /** * Runs an analysis script. * @param scriptName - The name of the script to run. * @param options - The options for running the script. * @returns void */ -async function runAnalysis( - scriptName: string | undefined, - options: { environment: string; debug: boolean; clear: boolean; tsnd: boolean; deno: boolean; node: boolean }, -) { +async function runAnalysis(scriptName: string | undefined, options: RunAnalysisOptions) { // Analysis development requires a project directory. const scope = requireLocalScope("analysis-run"); @@ -112,10 +125,20 @@ async function runAnalysis( } } + // Interactive shortcuts (q/h/r/c + double-Ctrl-C) are only installed when + // stdin is a TTY and the caller did not pass `--no-interactive`. Outside a + // TTY (CI, piped stdin, Docker), the loop still works — it just won't + // respawn, because no keystroke handler ever flips `restartRequested`. + const isInteractive = Boolean(process.stdin.isTTY) && options.interactive !== false; + + // When shortcuts are on, the parent owns stdin exclusively so single keys + // (q/h/r/c) route to our handler and never leak to tsx, which would interpret + // them as its own watch-mode rerun triggers. Non-interactive mode keeps the + // legacy "inherit" so an analysis that reads stdin still works under CI. const spawnOptions: SpawnOptions = { shell: true, cwd: scope.root, - stdio: "inherit", + stdio: isInteractive ? ["ignore", "inherit", "inherit"] : "inherit", env: analysisEnv, }; @@ -147,10 +170,49 @@ async function runAnalysis( spawnOptions.env.T_ANALYSIS_TOKEN = analysisToken; } } - const spawnProccess = spawn(`${cmd}${scriptPath}`, spawnOptions); - const killAnalysis = async () => await account.analysis.edit(scriptToRun.id, { run_on: "tago" }); - spawnProccess.on("close", killAnalysis); - spawnProccess.on("SIGINT", killAnalysis); + let restartRequested = false; + let quitRequested = false; + let child: ChildProcess | undefined; + + const teardown = installWatchShortcuts( + { + onQuit: () => { + if (quitRequested) { + return; + } + quitRequested = true; + infoMSG("Stopping analysis..."); + child?.kill("SIGTERM"); + }, + onRestart: () => { + if (restartRequested) { + return; + } + restartRequested = true; + infoMSG("Restarting analysis..."); + child?.kill("SIGTERM"); + }, + }, + { enabled: isInteractive }, + ); + + try { + do { + restartRequested = false; + // `exec` replaces the shell process with the analysis runtime so they + // share a PID — without it, killing the child only kills `sh -c …` and + // leaves the inner tsx/deno/tsnd process running as a zombie that keeps + // printing to the terminal (looks like a phantom restart on every key). + child = spawn(`exec ${cmd}"${scriptPath}"`, spawnOptions); + if (isInteractive) { + infoMSG("Watching for changes. Press h for help, r to restart, q to quit."); + } + await new Promise((resolve) => child?.once("close", () => resolve())); + } while (restartRequested && !quitRequested); + } finally { + teardown(); + await account.analysis.edit(scriptToRun.id, { run_on: "tago" }); + } } export { runAnalysis, _buildCMD }; diff --git a/src/lib/__snapshots__/generate-man.test.ts.snap b/src/lib/__snapshots__/generate-man.test.ts.snap index 2b3dbd4..a17ca83 100644 --- a/src/lib/__snapshots__/generate-man.test.ts.snap +++ b/src/lib/__snapshots__/generate-man.test.ts.snap @@ -172,8 +172,17 @@ Force build for Deno runtime (default: false) .TP \\fB\\-\\-node\\fR Force build for Node.js runtime (default: false) +.TP +\\fB\\-\\-no\\-interactive\\fR +disable single\\-key shortcuts (q/h/r/c); shortcuts default on when stdin is a TTY .PP .nf + Shortcuts (when stdin is a TTY, opt out with \\-\\-no\\-interactive): + r restart the analysis + c clear the screen + h, ? show shortcut help + q, Ctrl\\-C quit (press Ctrl\\-C twice within 2s to force quit) + Example: $ tagoio run dashboard\\-handler $ tagoio run dash @@ -183,6 +192,7 @@ Example: $ tagoio run dashboard\\-handler \\-\\-node $ tagoio run \\-\\-deno $ tagoio run \\-\\-node + $ tagoio run dashboard\\-handler \\-\\-no\\-interactive # CI / piped logs .fi .SS analysis\\-trigger [name] send a signal to trigger your analysis TagoIO diff --git a/src/lib/messages.ts b/src/lib/messages.ts index d8e1573..70f62ca 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -126,5 +126,5 @@ function infoMSG(str: any) { writeStatus(`[${kleur.blue("INFO")}] ${str}`); } -export { errorHandler, errorHandlerJSON, highlightMSG, infoMSG, requireOrFail, successMSG }; +export { errorHandler, errorHandlerJSON, highlightMSG, infoMSG, requireOrFail, successMSG, writeStatus }; export type { RequireOrFailOptions }; diff --git a/src/lib/watch-shortcuts.test.ts b/src/lib/watch-shortcuts.test.ts new file mode 100644 index 0000000..c859087 --- /dev/null +++ b/src/lib/watch-shortcuts.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { installWatchShortcuts } from "./watch-shortcuts.js"; + +// Tests drive the keypress handler by emitting synthetic events on +// `process.stdin` — see vitest's own watch-mode tests for the same pattern. +// `setRawMode` is a TTY-only method, so we stub it on the stream before each +// test and restore it after. +describe("installWatchShortcuts", () => { + let stderrSpy: ReturnType; + let setRawModeMock: ReturnType; + let originalIsTTY: boolean | undefined; + let originalSetRawMode: typeof process.stdin.setRawMode | undefined; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + setRawModeMock = vi.fn(); + originalIsTTY = process.stdin.isTTY; + originalSetRawMode = process.stdin.setRawMode; + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + process.stdin.setRawMode = setRawModeMock as never; + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsTTY }); + process.stdin.setRawMode = originalSetRawMode as typeof process.stdin.setRawMode; + process.stdin.removeAllListeners("keypress"); + }); + + test("enabled: false returns a no-op teardown and never touches setRawMode", () => { + const onQuit = vi.fn(); + const teardown = installWatchShortcuts({ onQuit, onRestart: vi.fn() }, { enabled: false }); + expect(setRawModeMock).not.toHaveBeenCalled(); + teardown(); + process.stdin.emit("keypress", "q", { name: "q" }); + expect(onQuit).not.toHaveBeenCalled(); + }); + + test("q triggers onQuit exactly once per press", () => { + const onQuit = vi.fn(); + installWatchShortcuts({ onQuit, onRestart: vi.fn() }, { enabled: true }); + process.stdin.emit("keypress", "q", { name: "q" }); + expect(onQuit).toHaveBeenCalledTimes(1); + }); + + test("r triggers onRestart and can be pressed repeatedly across runs", () => { + const onRestart = vi.fn(); + installWatchShortcuts({ onQuit: vi.fn(), onRestart }, { enabled: true }); + process.stdin.emit("keypress", "r", { name: "r" }); + process.stdin.emit("keypress", "r", { name: "r" }); + process.stdin.emit("keypress", "r", { name: "r" }); + expect(onRestart).toHaveBeenCalledTimes(3); + }); + + test("c invokes the default clear handler (writes ANSI clear to stderr)", () => { + installWatchShortcuts({ onQuit: vi.fn(), onRestart: vi.fn() }, { enabled: true }); + process.stdin.emit("keypress", "c", { name: "c" }); + expect(stderrSpy).toHaveBeenCalledWith("\x1Bc"); + }); + + test("c uses the caller-supplied onClear when provided", () => { + const onClear = vi.fn(); + installWatchShortcuts({ onQuit: vi.fn(), onRestart: vi.fn(), onClear }, { enabled: true }); + process.stdin.emit("keypress", "c", { name: "c" }); + expect(onClear).toHaveBeenCalledOnce(); + }); + + test("h prints the help block to stderr", () => { + installWatchShortcuts({ onQuit: vi.fn(), onRestart: vi.fn() }, { enabled: true }); + process.stdin.emit("keypress", "h", { name: "h" }); + const written = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(written).toMatch(/restart/); + expect(written).toMatch(/quit/); + }); + + test("? also prints the help block (sequence-based detection)", () => { + installWatchShortcuts({ onQuit: vi.fn(), onRestart: vi.fn() }, { enabled: true }); + process.stdin.emit("keypress", "?", { sequence: "?", shift: true }); + const written = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(written).toMatch(/Shortcuts/); + }); + + test("double Ctrl-C within the force-quit window calls onForceQuit", () => { + const onQuit = vi.fn(); + const onForceQuit = vi.fn(() => { + throw new Error("force-quit"); + }); + installWatchShortcuts( + { onQuit, onRestart: vi.fn(), onForceQuit: onForceQuit as unknown as () => never }, + { enabled: true, forceQuitWindowMs: 1000 }, + ); + + process.stdin.emit("keypress", "\x03", { ctrl: true, name: "c" }); + expect(onQuit).toHaveBeenCalledTimes(1); + expect(onForceQuit).not.toHaveBeenCalled(); + + expect(() => process.stdin.emit("keypress", "\x03", { ctrl: true, name: "c" })).toThrow(/force-quit/); + expect(onForceQuit).toHaveBeenCalledOnce(); + }); + + test("double Ctrl-C OUTSIDE the window calls onQuit twice (no force)", async () => { + const onQuit = vi.fn(); + const onForceQuit = vi.fn(); + installWatchShortcuts( + { onQuit, onRestart: vi.fn(), onForceQuit: onForceQuit as unknown as () => never }, + { enabled: true, forceQuitWindowMs: 5 }, + ); + + process.stdin.emit("keypress", "\x03", { ctrl: true, name: "c" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + process.stdin.emit("keypress", "\x03", { ctrl: true, name: "c" }); + + expect(onQuit).toHaveBeenCalledTimes(2); + expect(onForceQuit).not.toHaveBeenCalled(); + }); + + test("unknown keys are dropped silently", () => { + const onQuit = vi.fn(); + const onRestart = vi.fn(); + installWatchShortcuts({ onQuit, onRestart }, { enabled: true }); + expect(() => { + process.stdin.emit("keypress", "x", { name: "x" }); + process.stdin.emit("keypress", "z", { name: "z" }); + process.stdin.emit("keypress", "", undefined); + }).not.toThrow(); + expect(onQuit).not.toHaveBeenCalled(); + expect(onRestart).not.toHaveBeenCalled(); + }); + + test("teardown restores cooked mode and removes the keypress listener", () => { + const onQuit = vi.fn(); + const teardown = installWatchShortcuts({ onQuit, onRestart: vi.fn() }, { enabled: true }); + expect(setRawModeMock).toHaveBeenCalledWith(true); + + teardown(); + expect(setRawModeMock).toHaveBeenCalledWith(false); + + process.stdin.emit("keypress", "q", { name: "q" }); + expect(onQuit).not.toHaveBeenCalled(); + }); + + test("teardown is idempotent", () => { + const teardown = installWatchShortcuts({ onQuit: vi.fn(), onRestart: vi.fn() }, { enabled: true }); + teardown(); + expect(() => teardown()).not.toThrow(); + // setRawMode(false) should only have fired once across both teardown calls. + const falseCalls = setRawModeMock.mock.calls.filter(([arg]) => arg === false); + expect(falseCalls).toHaveLength(1); + }); + + test("setRawMode is skipped when stdin is not a TTY even with enabled: true", () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + const onQuit = vi.fn(); + installWatchShortcuts({ onQuit, onRestart: vi.fn() }, { enabled: true }); + expect(setRawModeMock).not.toHaveBeenCalled(); + // The keypress listener is still wired, so emitted events still dispatch. + process.stdin.emit("keypress", "q", { name: "q" }); + expect(onQuit).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/watch-shortcuts.ts b/src/lib/watch-shortcuts.ts new file mode 100644 index 0000000..4f7b649 --- /dev/null +++ b/src/lib/watch-shortcuts.ts @@ -0,0 +1,140 @@ +import { emitKeypressEvents } from "node:readline"; + +import { writeStatus } from "./messages.js"; + +interface WatchShortcutHandlers { + /** Called on `q` and on the first Ctrl-C press. */ + onQuit: () => void | Promise; + /** Called on `r`. */ + onRestart: () => void | Promise; + /** Called on `c`. Defaults to writing the ANSI clear sequence to stderr. */ + onClear?: () => void; + /** Called on the second Ctrl-C within the force-quit window. Defaults to `process.exit(130)`. */ + onForceQuit?: () => never; +} + +interface WatchShortcutOptions { + /** Hard gate. Caller should pass `process.stdin.isTTY && options.interactive !== false`. */ + enabled: boolean; + /** Window (ms) within which a second Ctrl-C triggers force-quit. Default 2000. */ + forceQuitWindowMs?: number; +} + +interface KeypressKey { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +} + +const DEFAULT_FORCE_QUIT_WINDOW_MS = 2000; +const ANSI_CLEAR = "\x1Bc"; + +function defaultClear(): void { + process.stderr.write(ANSI_CLEAR); +} + +function defaultForceQuit(): never { + process.exit(130); +} + +/** Writes the shortcut help block to stderr. Reused by the `h` / `?` keys. */ +function printShortcutHelp(): void { + writeStatus(""); + writeStatus("Shortcuts:"); + writeStatus(" r restart the analysis"); + writeStatus(" c clear the screen"); + writeStatus(" h, ? show this help"); + writeStatus(" q, Ctrl-C quit (press Ctrl-C twice within 2s to force quit)"); + writeStatus(""); +} + +/** + * Installs single-key shortcut handlers on `process.stdin` while a long-running + * command (e.g. `tagoio run`) is active. Returns an idempotent teardown that + * restores cooked mode and removes the listener. Safe to call with + * `enabled: false` — returns a no-op teardown without touching stdin. + * + * Modeled on vitest's watch-mode handler — same Node primitives + * (`readline.emitKeypressEvents` + `process.stdin.setRawMode`), no libraries. + */ +function installWatchShortcuts(handlers: WatchShortcutHandlers, options: WatchShortcutOptions): () => void { + if (!options.enabled) { + return () => {}; + } + + const forceQuitWindowMs = options.forceQuitWindowMs ?? DEFAULT_FORCE_QUIT_WINDOW_MS; + let lastCtrlCAt = 0; + let torn = false; + + const stdin = process.stdin; + emitKeypressEvents(stdin); + if (stdin.isTTY) { + stdin.setRawMode(true); + } + // `emitKeypressEvents` alone does not flip stdin into flowing mode. With the + // child spawned at `stdio: ["ignore", …]`, nothing else is reading the fd, + // so without `resume()` the keypress events never fire. + stdin.resume(); + + const onKeypress = (_str: string, key: KeypressKey | undefined) => { + if (!key) { + return; + } + + if (key.ctrl && key.name === "c") { + const now = Date.now(); + if (lastCtrlCAt && now - lastCtrlCAt <= forceQuitWindowMs) { + (handlers.onForceQuit ?? defaultForceQuit)(); + return; + } + lastCtrlCAt = now; + void handlers.onQuit(); + return; + } + + if (key.name === "h" || key.sequence === "?") { + printShortcutHelp(); + return; + } + + switch (key.name) { + case "q": + void handlers.onQuit(); + return; + case "r": + void handlers.onRestart(); + return; + case "c": + (handlers.onClear ?? defaultClear)(); + return; + default: + return; + } + }; + + stdin.on("keypress", onKeypress); + + const teardown = () => { + if (torn) { + return; + } + torn = true; + stdin.removeListener("keypress", onKeypress); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.pause(); + process.off("exit", teardown); + }; + + // Defence-in-depth: if the parent crashes before the caller's finally runs, + // restore cooked mode so the user's terminal does not get stuck in raw mode. + process.once("exit", teardown); + + return teardown; +} + +export { installWatchShortcuts, printShortcutHelp }; +export type { WatchShortcutHandlers, WatchShortcutOptions };