diff --git a/.changeset/listage-improvements.md b/.changeset/listage-improvements.md new file mode 100644 index 00000000..4101e0a4 --- /dev/null +++ b/.changeset/listage-improvements.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add scroll indicators ("↑ N more above" / "↓ N more below") to interactive list prompts when choices overflow the visible page. Add interactive environment picker to `clerk switch-env` when no argument is given. diff --git a/bun.lock b/bun.lock index 67fc661e..a215e970 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,9 @@ }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", + "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.7", + "@inquirer/figures": "^2.0.5", "@inquirer/prompts": "^8.4.1", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 2dfb00c9..8d7ec069 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -17,7 +17,9 @@ }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", + "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.7", + "@inquirer/figures": "^2.0.5", "@inquirer/prompts": "^8.4.1", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 19e3de0f..0ddad502 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { captureLog, promptsStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ @@ -63,6 +63,15 @@ mock.module("@inquirer/prompts", () => ({ confirm: async () => confirmResponses.shift(), })); +mock.module("../../lib/prompts.ts", () => ({ + confirm: async () => confirmResponses.shift(), +})); + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + select: async () => selectResponses.shift(), +})); + describe("apiInteractive", () => { let tempDir: string; let errorSpy: ReturnType; diff --git a/packages/cli-core/src/commands/api/interactive.ts b/packages/cli-core/src/commands/api/interactive.ts index 6b2e4657..1f543f7e 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -2,7 +2,9 @@ * Interactive API request builder for `clerk api` (no args, human mode). */ -import { select, input, confirm, editor } from "@inquirer/prompts"; +import { input, editor } from "@inquirer/prompts"; +import { select } from "../../lib/listage.ts"; +import { confirm } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; import { loadCatalog, endpointsByTag, type EndpointInfo } from "./catalog.ts"; import type { ApiOptions } from "./index.ts"; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index ad69fc87..9805ccc2 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -28,6 +28,15 @@ mock.module("@inquirer/prompts", () => ({ password: (...args: unknown[]) => mockPassword(...args), })); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + select: (...args: unknown[]) => mockSelect(...args), +})); + const { deploy } = await import("./index.ts"); describe("deploy", () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 648d7c66..4f7b8023 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,4 +1,6 @@ -import { select, input, confirm, password } from "@inquirer/prompts"; +import { input, password } from "@inquirer/prompts"; +import { select } from "../../lib/listage.ts"; +import { confirm } from "../../lib/prompts.ts"; import { isAgent } from "../../mode.ts"; import { dim, bold, cyan, green, blue } from "../../lib/color.ts"; import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 8c36f38e..1f6b0fe4 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,5 +1,7 @@ import { join } from "node:path"; -import { search, confirm, input } from "@inquirer/prompts"; +import { input } from "@inquirer/prompts"; +import { confirm } from "../../lib/prompts.ts"; +import { search, filterChoices } from "../../lib/listage.ts"; import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; import { log } from "../../lib/log.js"; import type { FrameworkInfo } from "../../lib/framework.js"; @@ -32,12 +34,6 @@ const PM_CHOICES: Array<{ name: string; value: PackageManager }> = [ { name: "npm", value: "npm" }, ]; -function filterChoices(choices: T[], term: string | undefined): T[] { - if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c) => c.name.toLowerCase().includes(lower)); -} - async function pickFramework(frameworkOverride?: FrameworkInfo): Promise { if (!frameworkOverride) { const chosen = await search({ diff --git a/packages/cli-core/src/commands/init/preview.ts b/packages/cli-core/src/commands/init/preview.ts index db33d518..88e28834 100644 --- a/packages/cli-core/src/commands/init/preview.ts +++ b/packages/cli-core/src/commands/init/preview.ts @@ -1,4 +1,4 @@ -import { confirm } from "@inquirer/prompts"; +import { confirm } from "../../lib/prompts.ts"; import { cyan, dim, green, yellow } from "../../lib/color.js"; import { log } from "../../lib/log.js"; import type { FileAction, ScaffoldPlan } from "./frameworks/types.js"; diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 1e391884..e237121f 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -6,6 +6,7 @@ import { autolinkStubs, gitStubs, promptsStubs, + listageStubs, } from "../../test/lib/stubs.ts"; import { PlapiError } from "../../lib/errors.ts"; @@ -86,6 +87,15 @@ mock.module("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...args), })); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + search: (...args: unknown[]) => mockSearch(...args), +})); + mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index d88beadb..7e742a94 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,5 +1,7 @@ import { basename } from "node:path"; -import { search, confirm, input } from "@inquirer/prompts"; +import { input } from "@inquirer/prompts"; +import { search } from "../../lib/listage.ts"; +import { confirm } from "../../lib/prompts.ts"; import { isAgent } from "../../mode.ts"; import { getToken } from "../../lib/credential-store.ts"; import { login } from "../auth/login.ts"; diff --git a/packages/cli-core/src/commands/skill/install.ts b/packages/cli-core/src/commands/skill/install.ts index b1dd2584..8e865649 100644 --- a/packages/cli-core/src/commands/skill/install.ts +++ b/packages/cli-core/src/commands/skill/install.ts @@ -25,7 +25,7 @@ import { dim } from "../../lib/color.js"; import { isHuman } from "../../mode.js"; import { log } from "../../lib/log.js"; import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.js"; -import { select } from "../../lib/prompts.js"; +import { select } from "../../lib/listage.js"; import { type Runner, detectAvailableRunners, diff --git a/packages/cli-core/src/commands/switch-env/README.md b/packages/cli-core/src/commands/switch-env/README.md index 52b4e646..e59c3583 100644 --- a/packages/cli-core/src/commands/switch-env/README.md +++ b/packages/cli-core/src/commands/switch-env/README.md @@ -21,6 +21,8 @@ clerk switch-env production ## Behavior +- When called without an argument in interactive mode, shows an interactive picker listing available environments. +- When called without an argument in non-interactive mode (agent, piped stdin), prints the current environment and available options. - Validates the environment name against the set of available profiles (injected at build time via `CLI_ENV_PROFILES`). - Persists the selection in `~/.clerk/config.json` under the `environment` key. - All subsequent commands use the selected environment's API endpoints and OAuth client. diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index c7f9cf83..706be4de 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -1,8 +1,14 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts"; +import { + captureLog, + configStubs, + credentialStoreStubs, + listageStubs, +} from "../../test/lib/stubs.ts"; const mockSetEnvironment = mock(); const mockGetToken = mock(); +const mockSelect = mock(); let mockCurrentEnv = "production"; mock.module("../../lib/config.ts", () => ({ @@ -26,29 +32,50 @@ mock.module("../../lib/environment.ts", () => ({ }, })); +let _modeOverride: string | undefined; +mock.module("../../mode.ts", () => ({ + isAgent: () => _modeOverride === "agent", + isHuman: () => _modeOverride !== "agent", + setMode: (m: string) => { + _modeOverride = m; + }, + getMode: () => _modeOverride ?? "human", +})); + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + select: (...args: unknown[]) => mockSelect(...args), +})); + const { switchEnv } = await import("./index.ts"); describe("switch-env", () => { let logSpy: ReturnType; let captured: ReturnType; + const originalIsTTY = process.stdin.isTTY; beforeEach(() => { captured = captureLog(); + process.stdin.isTTY = true; }); afterEach(() => { captured.teardown(); mockSetEnvironment.mockReset(); mockGetToken.mockReset(); + mockSelect.mockReset(); mockCurrentEnv = "production"; + _modeOverride = undefined; logSpy?.mockRestore(); + process.stdin.isTTY = originalIsTTY; }); function runSwitchEnv(environment: string | undefined) { return captured.run(() => switchEnv(environment)); } - test("prints current environment when no argument given", async () => { + test("prints current environment in non-interactive mode", async () => { + _modeOverride = "agent"; logSpy = spyOn(console, "log").mockImplementation(() => {}); await runSwitchEnv(undefined); @@ -56,6 +83,19 @@ describe("switch-env", () => { expect(captured.err).toContain("Available environments: production, staging"); }); + test("shows interactive picker when no argument given in human mode", async () => { + mockSetEnvironment.mockResolvedValue(undefined); + mockGetToken.mockResolvedValue("some-token"); + mockSelect.mockResolvedValue("staging"); + + logSpy = spyOn(console, "log").mockImplementation(() => {}); + await runSwitchEnv(undefined); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(mockCurrentEnv).toBe("staging"); + expect(captured.out).toContain("Switched from production to staging."); + }); + test("switches to a valid environment", async () => { mockSetEnvironment.mockResolvedValue(undefined); mockGetToken.mockResolvedValue("some-token"); @@ -78,6 +118,11 @@ describe("switch-env", () => { expect(captured.out).toContain("Already on production environment."); }); + test("throws when no TTY is available in human mode", async () => { + process.stdin.isTTY = false; + await expect(runSwitchEnv(undefined)).rejects.toThrow("No interactive terminal available"); + }); + test("throws on invalid environment", async () => { await expect(runSwitchEnv("nonexistent")).rejects.toThrow( 'Unknown environment "nonexistent". Available environments: production, staging', diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index cc7f5593..07496cd8 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -17,40 +17,62 @@ import { } from "../../lib/environment.ts"; import { CliError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { isHuman } from "../../mode.ts"; +import { select } from "../../lib/listage.ts"; -export async function switchEnv(environment: string | undefined): Promise { +export async function switchEnv(environmentArg: string | undefined): Promise { const available = getAvailableEnvs(); + const current = getCurrentEnvName(); - // No argument: print current environment - if (!environment) { - const current = getCurrentEnvName(); - log.info(`Current environment: ${current}`); - log.info(`Available environments: ${available.join(", ")}`); - return; + // No argument: show interactive picker (human) or print info (non-interactive) + let target = environmentArg; + if (!target) { + if (isHuman() && available.length > 1 && process.stdin.isTTY) { + target = await select({ + message: "Switch to environment:", + choices: available.map((env) => ({ + name: env === current ? `${env} (current)` : env, + value: env, + })), + default: current, + }); + } else if (isHuman() && available.length > 1 && !process.stdin.isTTY) { + throw new CliError( + "No interactive terminal available — pass an environment name explicitly: `clerk switch-env `", + ); + } else if (available.length <= 1) { + log.info(`Current environment: ${current}`); + log.info("Only one environment configured — nothing to switch to."); + return; + } else { + log.info(`Current environment: ${current}`); + log.info(`Available environments: ${available.join(", ")}`); + return; + } } - if (!isValidEnv(environment)) { + if (!isValidEnv(target)) { throw new CliError( - `Unknown environment "${environment}". Available environments: ${available.join(", ")}`, + `Unknown environment "${target}". Available environments: ${available.join(", ")}`, ); } const previousEnv = getCurrentEnvName(); - if (previousEnv === environment) { - log.data(`Already on ${environment} environment.`); + if (previousEnv === target) { + log.data(`Already on ${target} environment.`); return; } // Update the in-memory state and persist - setCurrentEnv(environment); - await setEnvironment(environment); + setCurrentEnv(target); + await setEnvironment(target); - log.data(`Switched from ${previousEnv} to ${environment}.`); + log.data(`Switched from ${previousEnv} to ${target}.`); // Check if there's a stored token for the target environment const token = await getToken(); if (!token) { - log.data(`No credentials found for ${environment}. Run \`clerk auth login\` to authenticate.`); + log.data(`No credentials found for ${target}. Run \`clerk auth login\` to authenticate.`); } } diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index 700f6b68..3b550d28 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -35,6 +35,10 @@ mock.module("@inquirer/prompts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), })); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + const { unlink } = await import("./index.ts"); const mockProfile = { diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index 89fba416..243c2ec4 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -1,4 +1,4 @@ -import { confirm } from "@inquirer/prompts"; +import { confirm } from "../../lib/prompts.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { resolveProfile, removeProfile } from "../../lib/config.ts"; import { getGitRepoRoot } from "../../lib/git.ts"; diff --git a/packages/cli-core/src/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts new file mode 100644 index 00000000..2e21c765 --- /dev/null +++ b/packages/cli-core/src/lib/listage.test.ts @@ -0,0 +1,131 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { scrollBounds, withScrollIndicators, filterChoices, ttyContext } from "./listage.ts"; + +describe("scrollBounds", () => { + test("returns zeros when all items fit on page", () => { + expect(scrollBounds(5, 0, 7)).toEqual({ above: 0, below: 0 }); + expect(scrollBounds(7, 3, 7)).toEqual({ above: 0, below: 0 }); + }); + + test("at the top of a long list", () => { + // 20 items, active=0, pageSize=5 → first 5 visible + expect(scrollBounds(20, 0, 5)).toEqual({ above: 0, below: 15 }); + expect(scrollBounds(20, 1, 5)).toEqual({ above: 0, below: 15 }); + }); + + test("in the middle of a long list", () => { + // 20 items, active=10, pageSize=5, middle=2 → firstVisible=8 + const result = scrollBounds(20, 10, 5); + expect(result.above).toBe(8); + expect(result.below).toBe(7); + expect(result.above + result.below + 5).toBe(20); + }); + + test("near the bottom of a long list", () => { + // 20 items, active=19, pageSize=5 → last 5 visible + expect(scrollBounds(20, 19, 5)).toEqual({ above: 15, below: 0 }); + }); + + test("above + below + pageSize = totalItems (pageSize=5)", () => { + for (let active = 0; active < 20; active++) { + const { above, below } = scrollBounds(20, active, 5); + expect(above + below + 5).toBe(20); + } + }); + + test("above + below + pageSize = totalItems (pageSize=7, odd)", () => { + // Odd pageSize may drift by ±1 at boundaries but must never be catastrophically wrong + for (let active = 0; active < 20; active++) { + const { above, below } = scrollBounds(20, active, 7); + expect(above + below + 7).toBe(20); + } + }); +}); + +describe("withScrollIndicators", () => { + test("wraps page with indicator lines", () => { + const page = " item1\n❯ item2\n item3"; + const result = withScrollIndicators(page, 20, 10, 3); + const lines = result.split("\n"); + // Should always have top indicator, page lines, bottom indicator + expect(lines.length).toBe(5); // top + 3 page lines + bottom + expect(lines[0]).toContain("more above"); + expect(lines[4]).toContain("more below"); + }); + + test("shows empty placeholder lines at edges for stable height", () => { + const page = "❯ item1\n item2\n item3"; + // active=0, at top — above=0 but still shows a placeholder line + const result = withScrollIndicators(page, 10, 0, 3); + const lines = result.split("\n"); + expect(lines.length).toBe(5); + expect(lines[0]).toBe(" "); // empty placeholder + expect(lines[4]).toContain("more below"); + }); + + test("always renders both indicator lines for stable height", () => { + const page = "❯ item1\n item2\n item3"; + // Both at top (above=0) and bottom visible — both placeholders shown + const result = withScrollIndicators(page, 10, 0, 3); + const lines = result.split("\n"); + expect(lines.length).toBe(5); // top placeholder + 3 page lines + bottom + expect(lines[0]).toBe(" "); // empty top placeholder + expect(lines[4]).toContain("more below"); + }); +}); + +describe("filterChoices", () => { + const choices = [ + { name: "Next.js", value: "next" }, + { name: "React", value: "react" }, + { name: "Vue", value: "vue" }, + { name: "Nuxt", value: "nuxt" }, + ]; + + test("returns all choices when term is undefined", () => { + expect(filterChoices(choices, undefined)).toEqual(choices); + }); + + test("returns all choices when term is empty", () => { + expect(filterChoices(choices, "")).toEqual(choices); + }); + + test("filters case-insensitively", () => { + expect(filterChoices(choices, "NEXT")).toEqual([choices[0]!]); + expect(filterChoices(choices, "next")).toEqual([choices[0]!]); + }); + + test("matches partial names", () => { + const result = filterChoices(choices, "xt"); + expect(result).toEqual([choices[0]!, choices[3]!]); // Next.js, Nuxt + }); + + test("returns empty array when nothing matches", () => { + expect(filterChoices(choices, "angular")).toEqual([]); + }); +}); + +describe("ttyContext", () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + process.stdin.isTTY = originalIsTTY; + }); + + test("returns undefined when stdin is a TTY", () => { + process.stdin.isTTY = true; + expect(ttyContext()).toBeUndefined(); + }); + + test("returns context with input and close when stdin is not a TTY", () => { + process.stdin.isTTY = false; + const ctx = ttyContext(); + // On macOS/Linux with /dev/tty available, this should return a context + if (ctx) { + expect(ctx.input).toBeDefined(); + expect(typeof ctx.close).toBe("function"); + ctx.close(); + } + // On CI/Docker without a TTY, ttyContext may return undefined — both are valid + }); +}); diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts new file mode 100644 index 00000000..17594f40 --- /dev/null +++ b/packages/cli-core/src/lib/listage.ts @@ -0,0 +1,584 @@ +/** + * Interactive list prompts with scroll indicators. + * + * Custom select/search prompts built on @inquirer/core that show + * "↑ N more above" / "↓ N more below" when the list overflows the + * visible page. Also includes the piped-stdin TTY fallback so prompts + * work even when stdin has been consumed by a pipe. + */ + +import { createReadStream } from "node:fs"; +import { + createPrompt, + useState, + useKeypress, + usePrefix, + usePagination, + useRef, + useMemo, + useEffect, + isBackspaceKey, + isEnterKey, + isUpKey, + isDownKey, + isNumberKey, + isTabKey, + Separator, + ValidationError, + makeTheme, +} from "@inquirer/core"; +import type { Theme } from "@inquirer/core"; +import { cursorHide, cursorShow } from "@inquirer/ansi"; +import { styleText } from "node:util"; +import figures from "@inquirer/figures"; +import type { PartialDeep } from "@inquirer/type"; + +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + +const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; + +export function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { + if (process.stdin.isTTY) return undefined; + try { + const input = createReadStream(TTY_PATH); + // Swallow open errors (Docker without --tty, detached CI runners, Windows + // sessions without CONIN$) so the prompt falls back to the default stdin + // instead of crashing with an unhandled error event. + input.on("error", () => {}); + return { input, close: () => input.close() }; + } catch { + return undefined; + } +} + +/** Case-insensitive name filter — shared by search-based prompts. */ +export function filterChoices( + choices: T[], + term: string | undefined, +): T[] { + if (!term) return choices; + const lower = term.toLowerCase(); + return choices.filter((c) => c.name.toLowerCase().includes(lower)); +} + +// --------------------------------------------------------------------------- +// Scroll indicator helpers +// --------------------------------------------------------------------------- + +/** + * Calculate how many items sit above/below the visible page window. + * + * Approximates `usePagination`'s `usePointerPosition` logic for + * `loop: false`, assuming every item renders as a single line. + * + * Known imprecisions: + * - For odd `pageSize` values (e.g. 7, middle=3), the counts may be off + * by ±1 near the boundary where the window starts scrolling, because + * `usePagination` only slides once the active item would cross out of + * the visible range, whereas this function slides at `active > middle`. + * - Long labels that wrap in narrow terminals produce multi-line rendered + * items, causing the counts to drift from the actual rendered window. + * + * These are cosmetic — the indicator text ("3 more above") may be off by + * one in edge cases but the prompt remains fully functional. + */ +export function scrollBounds( + totalItems: number, + active: number, + pageSize: number, +): { above: number; below: number } { + if (totalItems <= pageSize) return { above: 0, below: 0 }; + + const middle = Math.floor(pageSize / 2); + const spaceBelow = totalItems - active; + + let firstVisible: number; + if (spaceBelow < pageSize - middle) { + // Near the bottom — window slides to show the last pageSize items. + firstVisible = totalItems - pageSize; + } else if (active <= middle) { + // Near the top — window starts at 0. + firstVisible = 0; + } else { + // Middle — active is roughly centered. + firstVisible = active - middle; + } + + const lastVisible = Math.min(firstVisible + pageSize - 1, totalItems - 1); + return { + above: firstVisible, + below: totalItems - 1 - lastVisible, + }; +} + +/** + * Wrap the page string returned by `usePagination` with scroll indicators. + * + * Always renders both indicator lines when called (even if count is 0) so + * the total height stays stable as the user scrolls — preventing terminal + * jitter from line-count changes between renders. + */ +export function withScrollIndicators( + page: string, + totalItems: number, + active: number, + effectivePageSize: number, +): string { + const { above, below } = scrollBounds(totalItems, active, effectivePageSize); + const top = above > 0 ? styleText("dim", ` ${figures.arrowUp} ${above} more above`) : " "; + const bottom = below > 0 ? styleText("dim", ` ${figures.arrowDown} ${below} more below`) : " "; + return [top, page, bottom].join("\n"); +} + +// --------------------------------------------------------------------------- +// Shared item helpers +// --------------------------------------------------------------------------- + +function isSelectable(item: T | Separator): item is T & { disabled?: boolean | string } { + return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; +} + +type NormalizedChoice = { + value: Value; + name: string; + short: string; + disabled: boolean | string; + description?: string; +}; + +function normalizeChoices( + choices: ReadonlyArray | Separator>, +): Array | Separator> { + return choices.map((choice) => { + if (Separator.isSeparator(choice)) return choice; + if (typeof choice !== "object" || choice === null || !("value" in (choice as object))) { + const name = String(choice); + return { value: choice as Value, name, short: name, disabled: false }; + } + const c = choice as SelectChoice; + const name = c.name ?? String(c.value); + const normalized: NormalizedChoice = { + value: c.value, + name, + short: c.short ?? name, + disabled: c.disabled ?? false, + }; + if (c.description) normalized.description = c.description; + return normalized; + }); +} + +// --------------------------------------------------------------------------- +// Select prompt +// --------------------------------------------------------------------------- + +type SelectTheme = { + icon: { cursor: string }; + style: { + disabled: (text: string) => string; + description: (text: string) => string; + keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; + }; + i18n: { disabledError: string }; +}; + +type SelectChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +export type SelectConfig = { + message: string; + choices: ReadonlyArray>; + pageSize?: number; + default?: Value; + theme?: PartialDeep>; +}; + +const selectTheme: SelectTheme = { + icon: { cursor: figures.pointer }, + style: { + disabled: (text: string) => styleText("dim", text), + description: (text: string) => styleText("cyan", text), + keysHelpTip: (keys: [key: string, action: string][]) => + keys + .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) + .join(styleText("dim", " • ")), + }, + i18n: { disabledError: "This option is disabled and cannot be selected." }, +}; + +const rawSelect = createPrompt>((config, done) => { + const { pageSize = 7 } = config; + const theme = makeTheme(selectTheme, config.theme); + const [status, setStatus] = useState("idle"); + const prefix = usePrefix({ status, theme }); + const searchTimeoutRef = useRef>(); + + const items = useMemo(() => normalizeChoices(config.choices), [config.choices]); + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + const last = items.findLastIndex(isSelectable); + if (first === -1) { + throw new ValidationError("[select prompt] No selectable choices. All choices are disabled."); + } + return { first, last }; + }, [items]); + + const defaultItemIndex = useMemo(() => { + if (!("default" in config)) return -1; + return items.findIndex((item) => isSelectable(item) && item.value === config.default); + }, [config.default, items]); + + const [active, setActive] = useState(defaultItemIndex === -1 ? bounds.first : defaultItemIndex); + + const selectedChoice = items[active]; + if (selectedChoice == null || Separator.isSeparator(selectedChoice)) { + throw new Error("Active index does not point to a choice"); + } + + const [errorMsg, setError] = useState(); + + useKeypress((key, rl) => { + clearTimeout(searchTimeoutRef.current); + if (errorMsg) setError(undefined); + + if (isEnterKey(key)) { + if (selectedChoice.disabled) { + setError(theme.i18n.disabledError); + } else { + setStatus("done"); + done(selectedChoice.value); + } + } else if (isUpKey(key) || isDownKey(key)) { + rl.clearLine(0); + if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = (next + offset + items.length) % items.length; + } while (!isSelectable(items[next]!)); + setActive(next); + } + } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) { + const selectedIndex = Number(rl.line) - 1; + let selectableIndex = -1; + const position = items.findIndex((item) => { + if (Separator.isSeparator(item)) return false; + selectableIndex++; + return selectableIndex === selectedIndex; + }); + const item = items[position]; + if (item != null && isSelectable(item)) setActive(position); + searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); + } else if (isBackspaceKey(key)) { + rl.clearLine(0); + } else { + // Type-ahead search + const searchTerm = rl.line.toLowerCase(); + const matchIndex = items.findIndex( + (item) => isSelectable(item) && item.name.toLowerCase().startsWith(searchTerm), + ); + if (matchIndex !== -1) setActive(matchIndex); + searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); + } + }); + + useEffect(() => () => clearTimeout(searchTimeoutRef.current), []); + + const message = theme.style.message(config.message, status); + const helpLine = theme.style.keysHelpTip([ + ["↑↓", "navigate"], + ["⏎", "select"], + ]); + + // Pagination with scroll indicators + const needsScroll = items.length > pageSize; + const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; + + const page = usePagination({ + items, + active, + renderItem({ item, isActive }) { + if (Separator.isSeparator(item)) return ` ${item.separator}`; + const cursor = isActive ? theme.icon.cursor : " "; + if (item.disabled) { + const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; + const disabledCursor = isActive ? theme.icon.cursor : "-"; + return theme.style.disabled(`${disabledCursor} ${item.name} ${disabledLabel}`); + } + const color = isActive ? theme.style.highlight : (x: string) => x; + return color(`${cursor} ${item.name}`); + }, + pageSize: effectivePageSize, + loop: false, + }); + + if (status === "done") { + return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ")}${cursorShow}`; + } + + const pageWithScroll = needsScroll + ? withScrollIndicators(page, items.length, active, effectivePageSize) + : page; + + const { description } = selectedChoice; + const lines = [ + [prefix, message].filter(Boolean).join(" "), + pageWithScroll, + " ", + description ? theme.style.description(description) : "", + errorMsg ? theme.style.error(errorMsg) : "", + helpLine, + ] + .filter(Boolean) + .join("\n") + .trimEnd(); + + return `${lines}${cursorHide}`; +}); + +/** Select prompt with scroll indicators and piped-stdin TTY fallback. */ +export async function select(config: SelectConfig): Promise { + const tty = ttyContext(); + try { + return (await rawSelect( + config as SelectConfig, + tty ? { input: tty.input } : undefined, + )) as Value; + } finally { + tty?.close(); + } +} + +// --------------------------------------------------------------------------- +// Search prompt +// --------------------------------------------------------------------------- + +type SearchTheme = { + icon: { cursor: string }; + style: { + disabled: (text: string) => string; + searchTerm: (text: string) => string; + description: (text: string) => string; + keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; + }; +}; + +type SearchChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +export type SearchConfig = { + message: string; + source: ( + term: string | undefined, + opt: { signal: AbortSignal }, + ) => + | ReadonlyArray> + | Promise>>; + validate?: (value: Value) => boolean | string | Promise; + pageSize?: number; + default?: Value; + theme?: PartialDeep>; +}; + +const searchTheme: SearchTheme = { + icon: { cursor: figures.pointer }, + style: { + disabled: (text: string) => styleText("dim", `- ${text}`), + searchTerm: (text: string) => styleText("cyan", text), + description: (text: string) => styleText("cyan", text), + keysHelpTip: (keys: [key: string, action: string][]) => + keys + .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) + .join(styleText("dim", " • ")), + }, +}; + +const rawSearch = createPrompt>((config, done) => { + const { pageSize = 7, validate = () => true } = config; + const theme = makeTheme(searchTheme, config.theme); + const [status, setStatus] = useState("loading"); + const [searchTerm, setSearchTerm] = useState(""); + const [searchResults, setSearchResults] = useState | Separator>>( + [], + ); + const [searchError, setSearchError] = useState(); + const defaultApplied = useRef(false); + const prefix = usePrefix({ status, theme }); + + const bounds = useMemo(() => { + const first = searchResults.findIndex(isSelectable); + const last = searchResults.findLastIndex(isSelectable); + return { first, last }; + }, [searchResults]); + + const defaultActive = bounds.first === -1 ? 0 : bounds.first; + const [active = defaultActive, setActive] = useState(); + + useEffect(() => { + const controller = new AbortController(); + setStatus("loading"); + setSearchError(undefined); + + const fetchResults = async () => { + try { + const results = await config.source(searchTerm || undefined, { + signal: controller.signal, + }); + if (!controller.signal.aborted) { + const normalized = normalizeChoices(results as ReadonlyArray); + let initialActive: number | undefined; + if (!defaultApplied.current && "default" in config) { + const defaultIndex = normalized.findIndex( + (item) => isSelectable(item) && item.value === config.default, + ); + initialActive = defaultIndex === -1 ? undefined : defaultIndex; + defaultApplied.current = true; + } + setActive(initialActive); + setSearchError(undefined); + setSearchResults(normalized); + setStatus("idle"); + } + } catch (error: unknown) { + if (!controller.signal.aborted && error instanceof Error) { + setSearchError(error.message); + setStatus("idle"); + } + } + }; + + void fetchResults(); + return () => controller.abort(); + }, [searchTerm]); + + const selectedChoice = searchResults[active] as NormalizedChoice | undefined; + + useKeypress(async (key, rl) => { + if (isEnterKey(key)) { + if (selectedChoice) { + setStatus("loading"); + const isValid = await validate(selectedChoice.value); + setStatus("idle"); + if (isValid === true) { + setStatus("done"); + done(selectedChoice.value); + } else if (selectedChoice.name === searchTerm) { + setSearchError((isValid as string) || "You must provide a valid value"); + } else { + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); + } + } else { + rl.write(searchTerm); + } + } else if (isTabKey(key) && selectedChoice) { + rl.clearLine(0); + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); + } else if ( + status !== "loading" && + searchResults.length > 0 && + bounds.first !== -1 && + (isUpKey(key) || isDownKey(key)) + ) { + rl.clearLine(0); + if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = (next + offset + searchResults.length) % searchResults.length; + } while (!isSelectable(searchResults[next]!)); + setActive(next); + } + } else { + setSearchTerm(rl.line); + } + }); + + const message = theme.style.message(config.message, status); + const helpLine = theme.style.keysHelpTip([ + ["↑↓", "navigate"], + ["⏎", "select"], + ]); + + // Pagination with scroll indicators + const needsScroll = searchResults.length > pageSize; + const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; + + const page = usePagination({ + items: searchResults, + active, + renderItem({ item, isActive }) { + if (Separator.isSeparator(item)) return ` ${item.separator}`; + if (item.disabled) { + const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; + return theme.style.disabled(`${item.name} ${disabledLabel}`); + } + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : " "; + return color(`${cursor} ${item.name}`); + }, + pageSize: effectivePageSize, + loop: false, + }); + + let error: string | undefined; + if (searchError) { + error = theme.style.error(searchError); + } else if (searchResults.length === 0 && searchTerm !== "" && status === "idle") { + error = theme.style.error("No results found"); + } + + if (status === "done" && selectedChoice) { + return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ").trimEnd()}${cursorShow}`; + } + + const searchStr = theme.style.searchTerm(searchTerm); + + const pageWithScroll = + needsScroll && !error + ? withScrollIndicators(page, searchResults.length, active, effectivePageSize) + : page; + + const description = selectedChoice?.description; + const header = [prefix, message, searchStr].filter(Boolean).join(" ").trimEnd(); + const body = [ + error ?? pageWithScroll, + " ", + description ? theme.style.description(description) : "", + helpLine, + ] + .filter(Boolean) + .join("\n") + .trimEnd(); + + return [header, body]; +}); + +/** Search prompt with scroll indicators and piped-stdin TTY fallback. */ +export async function search(config: SearchConfig): Promise { + const tty = ttyContext(); + try { + return (await rawSearch( + config as SearchConfig, + tty ? { input: tty.input } : undefined, + )) as Value; + } finally { + tty?.close(); + } +} + +export { Separator }; diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index 2c391b13..b58eec1e 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -57,7 +57,7 @@ test("does not open tty when stdin is a TTY", async () => { test("opens controlling terminal as input when stdin is not a TTY", async () => { process.stdin.isTTY = false; - const mockStream = { close: mock(() => {}) }; + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( mockStream as any, ); @@ -77,7 +77,7 @@ test("closes tty stream even when confirm throws", async () => { process.stdin.isTTY = false; confirmResult = new Error("user cancelled"); - const mockStream = { close: mock(() => {}) }; + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( mockStream as any, ); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 3580c018..8ef126b0 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -6,14 +6,11 @@ * because stdin is at EOF. These helpers open the controlling terminal * as a fallback input so prompts can still read from the user's terminal. * - * Uses /dev/tty on Unix and CONIN$ on Windows. + * Uses the shared ttyContext from lib/listage.ts for consistent error handling. */ -import { createReadStream } from "node:fs"; -import { confirm as inquirerConfirm, select as inquirerSelect } from "@inquirer/prompts"; - -/** OS-specific path to the controlling terminal's input stream. */ -const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; +import { confirm as inquirerConfirm } from "@inquirer/prompts"; +import { ttyContext } from "./listage.ts"; /** * Like `confirm()` from @inquirer/prompts, but works even when stdin @@ -21,27 +18,10 @@ const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; * controlling terminal. */ export async function confirm(config: { message: string; default?: boolean }): Promise { - const ttyInput = process.stdin.isTTY ? undefined : createReadStream(TTY_PATH); - try { - return await inquirerConfirm(config, ttyInput ? { input: ttyInput } : undefined); - } finally { - ttyInput?.close(); - } -} - -/** - * Like `select()` from @inquirer/prompts, but with the same piped-stdin - * fallback as {@link confirm} above. - */ -export async function select(config: { - message: string; - choices: ReadonlyArray<{ name: string; value: T; description?: string }>; - default?: T; -}): Promise { - const ttyInput = process.stdin.isTTY ? undefined : createReadStream(TTY_PATH); + const tty = ttyContext(); try { - return await inquirerSelect(config, ttyInput ? { input: ttyInput } : undefined); + return await inquirerConfirm(config, tty ? { input: tty.input } : undefined); } finally { - ttyInput?.close(); + tty?.close(); } } diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index d1efa0e9..4c79b23e 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -162,6 +162,29 @@ mock.module("@inquirer/prompts", () => ({ editor: dequeuePrompt("editor"), })); +mock.module("../../../lib/listage.ts", () => ({ + select: dequeuePrompt("select"), + search: dequeuePrompt("search"), + filterChoices: (choices: T[], term: string | undefined): T[] => { + if (!term) return choices; + return choices.filter((c: T) => c.name.toLowerCase().includes(term.toLowerCase())); + }, + Separator: class Separator { + separator: string; + constructor(separator = "──────") { + this.separator = separator; + } + static isSeparator(item: unknown): item is Separator { + return item instanceof Separator; + } + }, + ttyContext: () => undefined, +})); + +mock.module("../../../lib/prompts.ts", () => ({ + confirm: dequeuePrompt("confirm"), +})); + mock.module( "../../../lib/token-exchange.ts", () => diff --git a/packages/cli-core/src/test/lib/listage-stubs.ts b/packages/cli-core/src/test/lib/listage-stubs.ts new file mode 100644 index 00000000..89a7f6a8 --- /dev/null +++ b/packages/cli-core/src/test/lib/listage-stubs.ts @@ -0,0 +1,9 @@ +import { filterChoices, Separator } from "../../lib/listage.ts"; + +export const listageStubs = { + select: async () => undefined, + search: async () => undefined, + filterChoices, + Separator, + ttyContext: () => undefined, +}; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index ef5b204a..c357a413 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -80,6 +80,8 @@ export const promptsStubs = { editor: async () => "{}", }; +export { listageStubs } from "./listage-stubs.ts"; + export const tokenExchangeStubs = { exchangeCodeForToken: async () => ({}), fetchUserInfo: async () => ({}),