From 14a6ac739c3b707903f13b86404e51a863f729c4 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 16 Apr 2026 16:23:01 -0300 Subject: [PATCH 1/6] feat(prompts): add scroll indicators to interactive list prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom select/search prompts built on @inquirer/core that show "↑ N more above" / "↓ N more below" when the list overflows the visible page. Also consolidates the piped-stdin TTY fallback and the duplicated filterChoices helper into a single listage module. Commands updated to use the new prompts: - clerk link (app picker) - clerk init (framework/PM pickers) - clerk api (category/endpoint pickers) - clerk deploy (domain/OAuth pickers) - clerk switch-env (new interactive env picker when no arg given) --- .changeset/listage-improvements.md | 5 + bun.lock | 2 + packages/cli-core/package.json | 2 + .../src/commands/api/interactive.test.ts | 7 +- .../cli-core/src/commands/api/interactive.ts | 3 +- .../src/commands/deploy/index.test.ts | 7 +- .../cli-core/src/commands/deploy/index.ts | 3 +- .../cli-core/src/commands/init/bootstrap.ts | 9 +- .../cli-core/src/commands/link/index.test.ts | 6 + packages/cli-core/src/commands/link/index.ts | 3 +- .../src/commands/switch-env/index.test.ts | 41 +- .../cli-core/src/commands/switch-env/index.ts | 23 +- packages/cli-core/src/lib/listage.ts | 576 ++++++++++++++++++ .../src/test/integration/lib/harness.ts | 19 + packages/cli-core/src/test/lib/stubs.ts | 19 + 15 files changed, 706 insertions(+), 19 deletions(-) create mode 100644 .changeset/listage-improvements.md create mode 100644 packages/cli-core/src/lib/listage.ts 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..17409ec7 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,11 @@ mock.module("@inquirer/prompts", () => ({ 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..ac319586 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -2,7 +2,8 @@ * Interactive API request builder for `clerk api` (no args, human mode). */ -import { select, input, confirm, editor } from "@inquirer/prompts"; +import { input, confirm, editor } from "@inquirer/prompts"; +import { select } from "../../lib/listage.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..ebe14180 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,11 @@ mock.module("@inquirer/prompts", () => ({ password: (...args: unknown[]) => mockPassword(...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..c0be16d4 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,4 +1,5 @@ -import { select, input, confirm, password } from "@inquirer/prompts"; +import { input, confirm, password } from "@inquirer/prompts"; +import { select } from "../../lib/listage.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..8bfc499d 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; -import { search, confirm, input } from "@inquirer/prompts"; +import { confirm, input } from "@inquirer/prompts"; +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 +33,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/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 1e391884..f2ca46f9 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,11 @@ mock.module("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...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..d6cb1377 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,5 +1,6 @@ import { basename } from "node:path"; -import { search, confirm, input } from "@inquirer/prompts"; +import { confirm, input } from "@inquirer/prompts"; +import { search } from "../../lib/listage.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/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index c7f9cf83..3502568f 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,6 +32,21 @@ 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", () => { @@ -40,7 +61,9 @@ describe("switch-env", () => { captured.teardown(); mockSetEnvironment.mockReset(); mockGetToken.mockReset(); + mockSelect.mockReset(); mockCurrentEnv = "production"; + _modeOverride = undefined; logSpy?.mockRestore(); }); @@ -48,7 +71,8 @@ describe("switch-env", () => { 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 +80,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"); diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index cc7f5593..d7939b7b 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -17,16 +17,29 @@ 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 { const available = getAvailableEnvs(); + const current = getCurrentEnvName(); - // No argument: print current environment + // No argument: show interactive picker (human) or print info (non-interactive) if (!environment) { - const current = getCurrentEnvName(); - log.info(`Current environment: ${current}`); - log.info(`Available environments: ${available.join(", ")}`); - return; + if (isHuman() && available.length > 1) { + environment = await select({ + message: "Switch to environment:", + choices: available.map((env) => ({ + name: env === current ? `${env} (current)` : env, + value: env, + })), + default: current, + }); + } else { + log.info(`Current environment: ${current}`); + log.info(`Available environments: ${available.join(", ")}`); + return; + } } if (!isValidEnv(environment)) { diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts new file mode 100644 index 00000000..0211bda6 --- /dev/null +++ b/packages/cli-core/src/lib/listage.ts @@ -0,0 +1,576 @@ +/** + * 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 } 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"; + +function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { + if (process.stdin.isTTY) return undefined; + const input = createReadStream(TTY_PATH); + return { input, close: () => input.close() }; +} + +/** 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. + * + * Mirrors `usePagination`'s `usePointerPosition` logic for `loop: false` + * assuming every item renders as a single line (true for all CLI choices). + */ +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, + }; +} + +function formatIndicators(above: number, below: number): { top: string; bottom: string } { + return { + top: above > 0 ? styleText("dim", ` ↑ ${above} more above`) : "", + bottom: below > 0 ? styleText("dim", ` ↓ ${below} more below`) : "", + }; +} + +/** + * Wrap the page string returned by `usePagination` with scroll indicators. + * Reduces effective pageSize by up to 2 lines so the total height stays stable. + */ +function withScrollIndicators( + page: string, + totalItems: number, + active: number, + effectivePageSize: number, +): string { + const { above, below } = scrollBounds(totalItems, active, effectivePageSize); + if (above === 0 && below === 0) return page; + const { top, bottom } = formatIndicators(above, below); + return [top, page, bottom].filter(Boolean).join("\n"); +} + +// --------------------------------------------------------------------------- +// Shared item helpers +// --------------------------------------------------------------------------- + +function isSelectable(item: T | Separator): item is T & { disabled?: false } { + return !Separator.isSeparator(item) && !(item as { disabled?: boolean }).disabled; +} + +function isNavigable(item: T | Separator): boolean { + return !Separator.isSeparator(item); +} + +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 }; + indexMode: "hidden" | "number"; +}; + +type SelectChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +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." }, + indexMode: "hidden", +}; + +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(isNavigable); + const last = items.findLastIndex(isNavigable); + 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 (!isNavigable(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; + + let separatorCount = 0; + const page = usePagination({ + items, + active, + renderItem({ item, isActive, index }) { + if (Separator.isSeparator(item)) { + separatorCount++; + return ` ${item.separator}`; + } + const cursor = isActive ? theme.icon.cursor : " "; + const indexLabel = theme.indexMode === "number" ? `${index + 1 - separatorCount}. ` : ""; + if (item.disabled) { + const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; + const disabledCursor = isActive ? theme.icon.cursor : "-"; + return theme.style.disabled(`${disabledCursor} ${indexLabel}${item.name} ${disabledLabel}`); + } + const color = isActive ? theme.style.highlight : (x: string) => x; + return color(`${cursor} ${indexLabel}${item.name}`); + }, + pageSize: effectivePageSize, + loop: false, + }); + + if (status === "done") { + return [prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" "); + } + + 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; +}; + +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 [active = bounds.first, 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); + } + } + }; + + 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" && (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(); + } + + 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/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index d1efa0e9..923d4d05 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -162,6 +162,25 @@ 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; + const lower = term.toLowerCase(); + return choices.filter((c: T) => c.name.toLowerCase().includes(lower)); + }, + Separator: class Separator { + separator: string; + constructor(separator = "──────") { + this.separator = separator; + } + static isSeparator(item: unknown): item is Separator { + return item instanceof Separator; + } + }, +})); + mock.module( "../../../lib/token-exchange.ts", () => diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index ef5b204a..203d1cb5 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -80,6 +80,25 @@ export const promptsStubs = { editor: async () => "{}", }; +export const listageStubs = { + select: async () => undefined, + search: async () => undefined, + filterChoices: (choices: T[], term: string | undefined): T[] => { + if (!term) return choices; + const lower = term.toLowerCase(); + return choices.filter((c: T) => c.name.toLowerCase().includes(lower)); + }, + Separator: class Separator { + separator: string; + constructor(separator = "──────") { + this.separator = separator; + } + static isSeparator(item: unknown): item is Separator { + return item instanceof Separator; + } + }, +}; + export const tokenExchangeStubs = { exchangeCodeForToken: async () => ({}), fetchUserInfo: async () => ({}), From b03917bc9d84309021805742b8c94e0e69456d2b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 20 Apr 2026 11:12:40 -0300 Subject: [PATCH 2/6] fix(listage): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1: Handle /dev/tty open failures gracefully (Docker, CI, Windows) - H2: Document single-line assumption in scrollBounds - M1: Stable indicator height — always render both lines when scrolling - M3: Add listage.test.ts with tests for scrollBounds, withScrollIndicators, filterChoices - M5: Guard search active against -1 when results are empty - M6: Add cursorShow on done branch to restore cursor after prompt - M7: Migrate init/skills to listage select (single API surface) - M8: Update switch-env README with interactive picker behavior - Codex: Fix all-disabled deadlock (check isSelectable not isNavigable) - Codex: Reset status to idle on search fetch error (prevent stuck loading) - Nit: Fix parameter reassignment in switch-env - Nit: Fix isSelectable type narrowing for falsy disabled values --- .../src/commands/switch-env/README.md | 2 + .../cli-core/src/commands/switch-env/index.ts | 23 ++--- packages/cli-core/src/lib/listage.test.ts | 98 +++++++++++++++++++ packages/cli-core/src/lib/listage.ts | 61 +++++++----- 4 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 packages/cli-core/src/lib/listage.test.ts 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.ts b/packages/cli-core/src/commands/switch-env/index.ts index d7939b7b..9247eb72 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -20,14 +20,15 @@ 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: show interactive picker (human) or print info (non-interactive) - if (!environment) { + let target = environmentArg; + if (!target) { if (isHuman() && available.length > 1) { - environment = await select({ + target = await select({ message: "Switch to environment:", choices: available.map((env) => ({ name: env === current ? `${env} (current)` : env, @@ -42,28 +43,28 @@ export async function switchEnv(environment: string | undefined): Promise } } - 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/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts new file mode 100644 index 00000000..f726565f --- /dev/null +++ b/packages/cli-core/src/lib/listage.test.ts @@ -0,0 +1,98 @@ +import { test, expect, describe } from "bun:test"; +import { scrollBounds, withScrollIndicators, filterChoices } 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", () => { + for (let active = 0; active < 20; active++) { + const { above, below } = scrollBounds(20, active, 5); + expect(above + below + 5).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([]); + }); +}); diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index 0211bda6..ddfd6c00 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -28,7 +28,7 @@ import { makeTheme, } from "@inquirer/core"; import type { Theme } from "@inquirer/core"; -import { cursorHide } from "@inquirer/ansi"; +import { cursorHide, cursorShow } from "@inquirer/ansi"; import { styleText } from "node:util"; import figures from "@inquirer/figures"; import type { PartialDeep } from "@inquirer/type"; @@ -41,8 +41,16 @@ const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { if (process.stdin.isTTY) return undefined; - const input = createReadStream(TTY_PATH); - return { input, close: () => input.close() }; + 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. */ @@ -63,9 +71,13 @@ export function filterChoices( * Calculate how many items sit above/below the visible page window. * * Mirrors `usePagination`'s `usePointerPosition` logic for `loop: false` - * assuming every item renders as a single line (true for all CLI choices). + * assuming every item renders as a single line. This holds for all current + * CLI choices; long labels that wrap in narrow terminals will cause the + * counts to drift from the actual rendered window. A line-aware calculation + * would require access to `breakLines` and terminal width inside the render + * function, which is deferred until a real need arises. */ -function scrollBounds( +export function scrollBounds( totalItems: number, active: number, pageSize: number, @@ -94,35 +106,31 @@ function scrollBounds( }; } -function formatIndicators(above: number, below: number): { top: string; bottom: string } { - return { - top: above > 0 ? styleText("dim", ` ↑ ${above} more above`) : "", - bottom: below > 0 ? styleText("dim", ` ↓ ${below} more below`) : "", - }; -} - /** * Wrap the page string returned by `usePagination` with scroll indicators. - * Reduces effective pageSize by up to 2 lines so the total height stays stable. + * + * 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. */ -function withScrollIndicators( +export function withScrollIndicators( page: string, totalItems: number, active: number, effectivePageSize: number, ): string { const { above, below } = scrollBounds(totalItems, active, effectivePageSize); - if (above === 0 && below === 0) return page; - const { top, bottom } = formatIndicators(above, below); - return [top, page, bottom].filter(Boolean).join("\n"); + const top = above > 0 ? styleText("dim", ` ↑ ${above} more above`) : " "; + const bottom = below > 0 ? styleText("dim", ` ↓ ${below} more below`) : " "; + return [top, page, bottom].join("\n"); } // --------------------------------------------------------------------------- // Shared item helpers // --------------------------------------------------------------------------- -function isSelectable(item: T | Separator): item is T & { disabled?: false } { - return !Separator.isSeparator(item) && !(item as { disabled?: boolean }).disabled; +function isSelectable(item: T | Separator): item is T & { disabled?: boolean | string } { + return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; } function isNavigable(item: T | Separator): boolean { @@ -214,8 +222,8 @@ const rawSelect = createPrompt>((config, done) => const items = useMemo(() => normalizeChoices(config.choices), [config.choices]); const bounds = useMemo(() => { - const first = items.findIndex(isNavigable); - const last = items.findLastIndex(isNavigable); + 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."); } @@ -317,7 +325,7 @@ const rawSelect = createPrompt>((config, done) => }); if (status === "done") { - return [prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" "); + return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ")}${cursorShow}`; } const pageWithScroll = needsScroll @@ -420,7 +428,8 @@ const rawSearch = createPrompt>((config, done) => return { first, last }; }, [searchResults]); - const [active = bounds.first, setActive] = useState(); + const defaultActive = bounds.first === -1 ? 0 : bounds.first; + const [active = defaultActive, setActive] = useState(); useEffect(() => { const controller = new AbortController(); @@ -450,6 +459,7 @@ const rawSearch = createPrompt>((config, done) => } catch (error: unknown) { if (!controller.signal.aborted && error instanceof Error) { setSearchError(error.message); + setStatus("idle"); } } }; @@ -532,10 +542,7 @@ const rawSearch = createPrompt>((config, done) => } if (status === "done" && selectedChoice) { - return [prefix, message, theme.style.answer(selectedChoice.short)] - .filter(Boolean) - .join(" ") - .trimEnd(); + return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ").trimEnd()}${cursorShow}`; } const searchStr = theme.style.searchTerm(searchTerm); From 9b5263306e05a84ba6ac98a2602986d867d35b49 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 20 Apr 2026 14:00:35 -0300 Subject: [PATCH 3/6] fix(listage): export config types and remove duplicate select from prompts.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export SelectConfig and SearchConfig types for consumer use (M4 nit) - Remove select() from lib/prompts.ts — all consumers now use listage (M7) --- .../cli-core/src/commands/skill/install.ts | 2 +- packages/cli-core/src/lib/listage.ts | 4 ++-- packages/cli-core/src/lib/prompts.ts | 19 +------------------ 3 files changed, 4 insertions(+), 21 deletions(-) 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/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index ddfd6c00..5ab5adb8 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -190,7 +190,7 @@ type SelectChoice = { disabled?: boolean | string; }; -type SelectConfig = { +export type SelectConfig = { message: string; choices: ReadonlyArray>; pageSize?: number; @@ -383,7 +383,7 @@ type SearchChoice = { disabled?: boolean | string; }; -type SearchConfig = { +export type SearchConfig = { message: string; source: ( term: string | undefined, diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 3580c018..7497a6ee 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -10,7 +10,7 @@ */ import { createReadStream } from "node:fs"; -import { confirm as inquirerConfirm, select as inquirerSelect } from "@inquirer/prompts"; +import { confirm as inquirerConfirm } from "@inquirer/prompts"; /** OS-specific path to the controlling terminal's input stream. */ const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; @@ -28,20 +28,3 @@ export async function confirm(config: { message: string; default?: boolean }): P 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); - try { - return await inquirerSelect(config, ttyInput ? { input: ttyInput } : undefined); - } finally { - ttyInput?.close(); - } -} From 6ffe9b747f8e4d98c2655a2975fefb541f3d9a45 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 20 Apr 2026 15:45:39 -0300 Subject: [PATCH 4/6] fix(listage): address second round of review feedback - Guard arrow keys on empty search results to prevent NaN crash - Share ttyContext between listage.ts and prompts.ts (single impl) - Import real filterChoices/Separator in unit test stubs - Use figures.arrowUp/arrowDown for ASCII fallback on legacy terminals - Better UX for single-env switch-env (explicit "nothing to switch to") - Export ttyContext for reuse --- .../cli-core/src/commands/switch-env/index.ts | 4 ++++ packages/cli-core/src/lib/listage.ts | 13 +++++++++---- packages/cli-core/src/lib/prompts.test.ts | 4 ++-- packages/cli-core/src/lib/prompts.ts | 13 +++++-------- .../src/test/integration/lib/harness.ts | 4 ++-- .../cli-core/src/test/lib/listage-stubs.ts | 9 +++++++++ packages/cli-core/src/test/lib/stubs.ts | 19 +------------------ 7 files changed, 32 insertions(+), 34 deletions(-) create mode 100644 packages/cli-core/src/test/lib/listage-stubs.ts diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index 9247eb72..fb3494bc 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -36,6 +36,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise void } | undefined { +export function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { if (process.stdin.isTTY) return undefined; try { const input = createReadStream(TTY_PATH); @@ -120,8 +120,8 @@ export function withScrollIndicators( effectivePageSize: number, ): string { const { above, below } = scrollBounds(totalItems, active, effectivePageSize); - const top = above > 0 ? styleText("dim", ` ↑ ${above} more above`) : " "; - const bottom = below > 0 ? styleText("dim", ` ↓ ${below} more below`) : " "; + 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"); } @@ -492,7 +492,12 @@ const rawSearch = createPrompt>((config, done) => rl.clearLine(0); rl.write(selectedChoice.name); setSearchTerm(selectedChoice.name); - } else if (status !== "loading" && (isUpKey(key) || isDownKey(key))) { + } 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; 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 7497a6ee..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 } from "@inquirer/prompts"; - -/** OS-specific path to the controlling terminal's input stream. */ -const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; +import { ttyContext } from "./listage.ts"; /** * Like `confirm()` from @inquirer/prompts, but works even when stdin @@ -21,10 +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); + const tty = ttyContext(); try { - return await inquirerConfirm(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 923d4d05..e2bd4d08 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -167,8 +167,7 @@ mock.module("../../../lib/listage.ts", () => ({ search: dequeuePrompt("search"), filterChoices: (choices: T[], term: string | undefined): T[] => { if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c: T) => c.name.toLowerCase().includes(lower)); + return choices.filter((c: T) => c.name.toLowerCase().includes(term.toLowerCase())); }, Separator: class Separator { separator: string; @@ -179,6 +178,7 @@ mock.module("../../../lib/listage.ts", () => ({ return item instanceof Separator; } }, + ttyContext: () => undefined, })); mock.module( 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 203d1cb5..c357a413 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -80,24 +80,7 @@ export const promptsStubs = { editor: async () => "{}", }; -export const listageStubs = { - select: async () => undefined, - search: async () => undefined, - filterChoices: (choices: T[], term: string | undefined): T[] => { - if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c: T) => c.name.toLowerCase().includes(lower)); - }, - Separator: class Separator { - separator: string; - constructor(separator = "──────") { - this.separator = separator; - } - static isSeparator(item: unknown): item is Separator { - return item instanceof Separator; - } - }, -}; +export { listageStubs } from "./listage-stubs.ts"; export const tokenExchangeStubs = { exchangeCodeForToken: async () => ({}), From 5fba1b4d046cb334df531b8ee6165c6cde604e1a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 20 Apr 2026 18:04:55 -0300 Subject: [PATCH 5/6] fix(listage): arrow nav skips disabled items, clarify scrollBounds precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use isSelectable (not isNavigable) in select arrow-key handler so disabled items are skipped, matching upstream @inquirer/select behavior - Remove unused isNavigable function - Expand scrollBounds doc comment to explicitly note ±1 drift for odd pageSize values --- packages/cli-core/src/lib/listage.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index 1686afd0..1d7cea92 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -70,12 +70,19 @@ export function filterChoices( /** * Calculate how many items sit above/below the visible page window. * - * Mirrors `usePagination`'s `usePointerPosition` logic for `loop: false` - * assuming every item renders as a single line. This holds for all current - * CLI choices; long labels that wrap in narrow terminals will cause the - * counts to drift from the actual rendered window. A line-aware calculation - * would require access to `breakLines` and terminal width inside the render - * function, which is deferred until a real need arises. + * 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, @@ -133,10 +140,6 @@ function isSelectable(item: T | Separator): item is T & { disabled?: boolean return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; } -function isNavigable(item: T | Separator): boolean { - return !Separator.isSeparator(item); -} - type NormalizedChoice = { value: Value; name: string; @@ -262,7 +265,7 @@ const rawSelect = createPrompt>((config, done) => let next = active; do { next = (next + offset + items.length) % items.length; - } while (!isNavigable(items[next]!)); + } while (!isSelectable(items[next]!)); setActive(next); } } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) { From 4f8e2e6f9bd42ef502d12d64f6fcf142aa3ba5cc Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 20 Apr 2026 19:04:27 -0300 Subject: [PATCH 6/6] fix(listage): address third round of review feedback - Guard switch-env select() against non-interactive TTY (throws CliError with actionable message instead of hanging on EOF) - Remove unreachable indexMode:"number" branch and separatorCount from select renderItem (latent bug: separatorCount only counted visible items) - Migrate all confirm imports from @inquirer/prompts to lib/prompts.ts TTY-safe wrapper (link, deploy, unlink, api/interactive, init/bootstrap, init/preview) and update test mocks accordingly - Add ttyContext tests in listage.test.ts - Add scrollBounds pageSize=7 invariant test for odd pageSize coverage --- .../src/commands/api/interactive.test.ts | 4 ++ .../cli-core/src/commands/api/interactive.ts | 3 +- .../src/commands/deploy/index.test.ts | 4 ++ .../cli-core/src/commands/deploy/index.ts | 3 +- .../cli-core/src/commands/init/bootstrap.ts | 3 +- .../cli-core/src/commands/init/preview.ts | 2 +- .../cli-core/src/commands/link/index.test.ts | 4 ++ packages/cli-core/src/commands/link/index.ts | 3 +- .../src/commands/switch-env/index.test.ts | 8 ++++ .../cli-core/src/commands/switch-env/index.ts | 6 ++- .../src/commands/unlink/index.test.ts | 4 ++ .../cli-core/src/commands/unlink/index.ts | 2 +- packages/cli-core/src/lib/listage.test.ts | 39 +++++++++++++++++-- packages/cli-core/src/lib/listage.ts | 15 ++----- .../src/test/integration/lib/harness.ts | 4 ++ 15 files changed, 83 insertions(+), 21 deletions(-) diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 17409ec7..0ddad502 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -63,6 +63,10 @@ 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(), diff --git a/packages/cli-core/src/commands/api/interactive.ts b/packages/cli-core/src/commands/api/interactive.ts index ac319586..1f543f7e 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -2,8 +2,9 @@ * Interactive API request builder for `clerk api` (no args, human mode). */ -import { 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 ebe14180..9805ccc2 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -28,6 +28,10 @@ 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), diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index c0be16d4..4f7b8023 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,5 +1,6 @@ -import { 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 8bfc499d..1f6b0fe4 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; -import { 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"; 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 f2ca46f9..e237121f 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -87,6 +87,10 @@ 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), diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index d6cb1377..7e742a94 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,6 +1,7 @@ import { basename } from "node:path"; -import { 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/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index 3502568f..706be4de 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -52,9 +52,11 @@ 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(() => { @@ -65,6 +67,7 @@ describe("switch-env", () => { mockCurrentEnv = "production"; _modeOverride = undefined; logSpy?.mockRestore(); + process.stdin.isTTY = originalIsTTY; }); function runSwitchEnv(environment: string | undefined) { @@ -115,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 fb3494bc..07496cd8 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -27,7 +27,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise 1) { + if (isHuman() && available.length > 1 && process.stdin.isTTY) { target = await select({ message: "Switch to environment:", choices: available.map((env) => ({ @@ -36,6 +36,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise 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."); 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 index f726565f..2e21c765 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,5 +1,5 @@ -import { test, expect, describe } from "bun:test"; -import { scrollBounds, withScrollIndicators, filterChoices } from "./listage.ts"; +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", () => { @@ -26,12 +26,20 @@ describe("scrollBounds", () => { expect(scrollBounds(20, 19, 5)).toEqual({ above: 15, below: 0 }); }); - test("above + below + pageSize = totalItems", () => { + 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", () => { @@ -96,3 +104,28 @@ describe("filterChoices", () => { 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 index 1d7cea92..17594f40 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -182,7 +182,6 @@ type SelectTheme = { keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; }; i18n: { disabledError: string }; - indexMode: "hidden" | "number"; }; type SelectChoice = { @@ -212,7 +211,6 @@ const selectTheme: SelectTheme = { .join(styleText("dim", " • ")), }, i18n: { disabledError: "This option is disabled and cannot be selected." }, - indexMode: "hidden", }; const rawSelect = createPrompt>((config, done) => { @@ -304,24 +302,19 @@ const rawSelect = createPrompt>((config, done) => const needsScroll = items.length > pageSize; const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; - let separatorCount = 0; const page = usePagination({ items, active, - renderItem({ item, isActive, index }) { - if (Separator.isSeparator(item)) { - separatorCount++; - return ` ${item.separator}`; - } + renderItem({ item, isActive }) { + if (Separator.isSeparator(item)) return ` ${item.separator}`; const cursor = isActive ? theme.icon.cursor : " "; - const indexLabel = theme.indexMode === "number" ? `${index + 1 - separatorCount}. ` : ""; if (item.disabled) { const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; const disabledCursor = isActive ? theme.icon.cursor : "-"; - return theme.style.disabled(`${disabledCursor} ${indexLabel}${item.name} ${disabledLabel}`); + return theme.style.disabled(`${disabledCursor} ${item.name} ${disabledLabel}`); } const color = isActive ? theme.style.highlight : (x: string) => x; - return color(`${cursor} ${indexLabel}${item.name}`); + return color(`${cursor} ${item.name}`); }, pageSize: effectivePageSize, loop: false, diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index e2bd4d08..4c79b23e 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -181,6 +181,10 @@ mock.module("../../../lib/listage.ts", () => ({ ttyContext: () => undefined, })); +mock.module("../../../lib/prompts.ts", () => ({ + confirm: dequeuePrompt("confirm"), +})); + mock.module( "../../../lib/token-exchange.ts", () =>