diff --git a/.talismanrc b/.talismanrc index 2557289..23f34d9 100644 --- a/.talismanrc +++ b/.talismanrc @@ -143,7 +143,7 @@ fileignoreconfig: ignore_detectors: - filename - filename: src/environments/environment.update.prompt.test.ts - checksum: b83aa8da125e6a45b455cf03c50fe82cc41890343be9be0c2ccf0a01d09bd160 + checksum: 6653e245baac00cef348acfc0aa2f32d03a7ff63ca3c320ee56a0c6250775004 ignore_detectors: - filename - filename: src/environments/environment.presenter.ts @@ -228,7 +228,7 @@ fileignoreconfig: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: d46db51dcf83e6db92f5588c1649f0ba76599ccb9a65aca540b19dc0a71208a4 + checksum: c3276952d1eb16ea53f89363c9a70b59a19efc21fe087ea89ee70946c4b7cfc7 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -314,9 +314,9 @@ fileignoreconfig: - filename: src/environments/environment.errors.ts checksum: 3e17db3577ba944633f97063d208bdec57c833d4b6c38e203796a9fea43baacc - filename: src/core/prompt.ts - checksum: 3285bdbccc31cba5b50eafe8ed8be4750af6339078a3eaee6df1e0630979aa2c + checksum: e9154ec91c36f7a8e8c2f91ec13fa554cfbf5fe3b8506db44fb7c68bdef8efa8 - filename: src/core/prompt.test.ts - checksum: 2deacd402e8d7f79458ee6d3917205c6dbe5cd7e6a98a2e5c49e37ac1a9d57c4 + checksum: 7be5497acf4cee870667751d44aa230e309784df76dc9e95d5ec2c4e6e6a57e0 - filename: test/integration/environments-update.test.ts checksum: 4cd0106bf1881d373d23f063314627057d85cab0c43b793f013ffc2ca8859036 - filename: src/commands/launch/env/update.ts diff --git a/AGENTS.md b/AGENTS.md index cfe4acf..d698829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -384,8 +384,8 @@ payload inline. on an arrow or a digit and ignores every letter, so text can never become the answer and nothing needs validating. Every choice prompt (`askOption`, and the checkbox `askChoices`) passes `loop: false`, so the arrow stops at the last entry instead of wrapping to the first, and a `pageSize` of the choice count -capped at `PROMPT_PAGE_SIZE` (15), so a short list shows whole and a 100-entry picker scrolls in a -fixed window. `askChoices` refuses an empty selection at the prompt, naming . Project type, branch, Framework preset, Response mode and Contentstack +capped at `PROMPT_PAGE_SIZE` (7), so a list of seven or fewer shows whole and anything longer - the +eleven framework presets, the env:update checklist - scrolls seven at a time in a fixed window. `askChoices` refuses an empty selection at the prompt, naming . Project type, branch, Framework preset, Response mode and Contentstack Authentication are all asked this way. Prefer it: the `❯` always shows what Enter will take, where a search box hides the list the moment the filter matches nothing. The branch picker is on this side despite fetching `PICKER_PAGE_SIZE` branches, because `noteTruncation` already prints the escape @@ -407,6 +407,10 @@ answers with an `UnlistedAnswer`. The wrapper installs `refuseUnlisted` over `th its constructor, so that answer never leaves the prompt: inquirer renders `"my-site" is not one of the options. Pick one from the list.` and asks again. A caller's own `validate` is wrapped, not replaced, and still sees every listed answer. +The same wrapper makes the pickers scroll like the lists: `PROMPT_PAGE_SIZE` rows at a time, and the +arrow stops at the first and last entry. Upstream, the up and down arrow handlers wrap round and the +bundled inquirer 3 paginator repeats the list three times to look endless, so the wrapper overrides both +arrow handlers and swaps the paginator for `scrollWindow`, a window that stops at both ends. Together these are the prompt-side twin of `oneOf` on the flag side (`--cs-auth yes` has always been exit 2); the two now agree, and no prompt module needs its own check. diff --git a/bin/dev.js b/bin/dev.js index 7d7de4c..2ad69a4 100755 --- a/bin/dev.js +++ b/bin/dev.js @@ -1,13 +1,26 @@ #!/usr/bin/env node_modules/.bin/ts-node (async () => { const oclif = await import('@oclif/core'); - const { handleStandaloneFailure } = require('../dist/core/failure'); process.env.NODE_ENV = 'development'; oclif.settings.debug = true; + function failureHandler() { + for (const path of ['../src/core/failure', '../dist/core/failure']) { + try { + return require(path).handleStandaloneFailure; + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + throw error; + } + } + } + + return oclif.Errors.handle; + } + await oclif .run(process.argv.slice(2), __dirname) .then(() => oclif.flush()) - .catch(handleStandaloneFailure); + .catch((err) => failureHandler()(err)); })(); diff --git a/src/core/prompt.test.ts b/src/core/prompt.test.ts index 2fc3f9f..69bca1f 100644 --- a/src/core/prompt.test.ts +++ b/src/core/prompt.test.ts @@ -86,9 +86,9 @@ describe('prompt helpers', () => { it.each([ [3, 3], - [15, 15], - [16, 15], - [100, 15], + [7, 7], + [8, 7], + [100, 7], ])('shows %i choices %i rows at a time, so a short list is shown whole and a long one scrolls', async (count, rows) => { const { asked, ux } = scriptedUx(['c0']); const choices = Array.from({ length: count }, (_, index) => ({ name: `C${index}`, value: `c${index}` })); @@ -96,7 +96,7 @@ describe('prompt helpers', () => { await askOption(ux, 'Pick one', choices); expect((asked[0] as { pageSize: number }).pageSize).toBe(rows); - expect(PROMPT_PAGE_SIZE).toBe(15); + expect(PROMPT_PAGE_SIZE).toBe(7); }); it('cancels rather than accepting nothing at a list of options', async () => { diff --git a/src/core/prompt.ts b/src/core/prompt.ts index b2d47b4..f6cac47 100644 --- a/src/core/prompt.ts +++ b/src/core/prompt.ts @@ -76,7 +76,7 @@ export async function askOptionalText( return text === '' ? undefined : text; } -export const PROMPT_PAGE_SIZE = 15; +export const PROMPT_PAGE_SIZE = 7; function scrolling(choices: Choice[]): { loop: false; pageSize: number } { return { loop: false, pageSize: Math.min(choices.length, PROMPT_PAGE_SIZE) }; diff --git a/src/core/search-list.test.ts b/src/core/search-list.test.ts index 502b581..daf2cbc 100644 --- a/src/core/search-list.test.ts +++ b/src/core/search-list.test.ts @@ -1,10 +1,14 @@ +import { PROMPT_PAGE_SIZE } from './prompt'; import type { SearchListClass } from './search-list'; import { + MORE_CHOICES_HINT, UnlistedAnswer, answerFor, + launchPaginated, launchSearchList, refuseUnlisted, registerSearchList, + scrollWindow, startingPointer, unlistedMessage, utilitiesLoader, @@ -14,8 +18,12 @@ const load = utilitiesLoader(); const SearchList = load('inquirer-search-list') as SearchListClass; interface Prompt { - opt: { validate: (value: unknown, answers?: unknown) => unknown }; + opt: { validate: (value: unknown, answers?: unknown) => unknown; pageSize?: number }; pointer: number; + paginator: { paginate: unknown }; + render(): void; + onDownKey(): void; + onUpKey(): void; rl: { line: string }; selected: unknown; filterChoices(): void; @@ -73,6 +81,56 @@ describe('launchSearchList', () => { expect(prompt.getCurrentValue()).toBe('Gatsby'); }); + it('stops at the last choice instead of wrapping round to the first', () => { + const prompt = open({ default: 'Other' }); + prompt.render = jest.fn(); + + prompt.onDownKey(); + + expect(prompt.pointer).toBe(2); + expect(prompt.render).toHaveBeenCalled(); + }); + + it('stops at the first choice instead of wrapping round to the last', () => { + const prompt = open({}); + prompt.render = jest.fn(); + + prompt.onUpKey(); + + expect(prompt.pointer).toBe(0); + }); + + it('still moves between choices inside the list', () => { + const prompt = open({}); + prompt.render = jest.fn(); + + prompt.onDownKey(); + prompt.onDownKey(); + prompt.onUpKey(); + + expect(prompt.pointer).toBe(1); + }); + + it('stops at the last match of what was typed', () => { + const prompt = typed(open({}), 'Oth'); + prompt.render = jest.fn(); + + prompt.onDownKey(); + + expect(prompt.pointer).toBe(0); + }); + + it('shows PROMPT_PAGE_SIZE choices at a time through a window that does not repeat the list', () => { + const prompt = open({}); + + expect(prompt.opt.pageSize).toBe(PROMPT_PAGE_SIZE); + expect(prompt.paginator.paginate).toBe(scrollWindow); + }); + + it('keeps a page size the question sets itself', () => { + expect(open({ pageSize: 3 }).opt.pageSize).toBe(3); + }); + it('submits the highlighted match of what was typed', () => { const prompt = typed(open({}), 'Oth'); @@ -162,6 +220,56 @@ describe('launchSearchList', () => { }); }); +describe('scrollWindow', () => { + const LINES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n'); + const HINT = '\u001b[2m(Use ↑/↓ to see more options)\u001b[22m'; + const shown = (active: number) => scrollWindow(LINES, active, 5).split('\n'); + + it('shows a list that fits the page whole, with no hint', () => { + expect(scrollWindow('a\nb\nc', 2, 5)).toBe('a\nb\nc'); + }); + + it('starts at the top of a longer list and says there are more', () => { + expect(shown(0)).toEqual(['a', 'b', 'c', 'd', 'e', HINT]); + }); + + it('keeps the highlight in the middle while scrolling down', () => { + expect(shown(4)).toEqual(['c', 'd', 'e', 'f', 'g', HINT]); + }); + + it('ends at the last choice and never shows the first one again after it', () => { + expect(shown(7)).toEqual(['d', 'e', 'f', 'g', 'h', HINT]); + }); +}); + +describe('launchPaginated', () => { + interface Paginated { + paginator: { paginate(output: string, active: number): string }; + } + + class Upstream { + paginator = { + paginate: (output: string) => `${output}\n\u001b[2m(Move up and down to reveal more choices)\u001b[22m`, + }; + } + + it('points at the up and down arrows instead of the upstream wording', () => { + const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated; + + expect(new Prompt().paginator.paginate('a\nb', 0)).toBe(`a\nb\n\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`); + expect(MORE_CHOICES_HINT).toBe('(Use ↑/↓ to see more options)'); + }); + + it('leaves a page without the hint untouched', () => { + class Short { + paginator = { paginate: (output: string) => output }; + } + const Prompt = launchPaginated(Short as never) as unknown as new () => Paginated; + + expect(new Prompt().paginator.paginate('a\nb', 0)).toBe('a\nb'); + }); +}); + describe('answerFor', () => { it('answers with the value of the choice whose name was typed in full', () => { expect(answerFor(CHOICES, 'NextJs')).toBe('NextJs'); @@ -224,8 +332,8 @@ describe('registerSearchList', () => { registerSearchList(fakeLoad); - expect(loaded).toEqual(['inquirer', 'inquirer-search-list']); - expect(registered).toHaveLength(1); + expect(loaded).toEqual(['inquirer', 'inquirer-search-list', 'inquirer/lib/prompts/list', 'inquirer/lib/prompts/checkbox']); + expect(registered.map(([name]) => name)).toEqual(['search-list', 'list', 'checkbox']); expect(registered[0][0]).toBe('search-list'); expect((registered[0][1] as SearchListClass).prototype).toBeInstanceOf(SearchList); expect(registered[0][1]).not.toBe(SearchList); @@ -237,5 +345,11 @@ describe('registerSearchList', () => { registerSearchList(); expect((inquirer.prompt.prompts['search-list'] as SearchListClass).prototype).toBeInstanceOf(SearchList); + for (const type of ['list', 'checkbox']) { + const Upstream = load(`inquirer/lib/prompts/${type}`) as SearchListClass; + + expect((inquirer.prompt.prompts[type] as SearchListClass).prototype).toBeInstanceOf(Upstream); + expect(inquirer.prompt.prompts[type]).not.toBe(Upstream); + } }); }); diff --git a/src/core/search-list.ts b/src/core/search-list.ts index 912dcbb..2d93cc2 100644 --- a/src/core/search-list.ts +++ b/src/core/search-list.ts @@ -1,5 +1,8 @@ import { createRequire } from 'node:module'; +import { PROMPT_PAGE_SIZE } from './prompt'; +import { styled } from './style'; + export interface SearchChoice { name?: string; value: unknown; @@ -8,11 +11,13 @@ export interface SearchChoice { export type Validator = (value: unknown, answers?: unknown) => unknown; export interface SearchListPrompt { - opt: { default?: unknown; validate: Validator }; + opt: { default?: unknown; validate: Validator; pageSize?: number }; rl: { line: string }; pointer: number; list: SearchChoice[]; filterList: SearchChoice[]; + paginator: Paginator; + render(): void; getCurrentValue(line?: unknown): unknown; } @@ -51,12 +56,59 @@ export function startingPointer(list: SearchChoice[], initial: unknown): number return Math.max(index, 0); } +export const MORE_CHOICES_HINT = '(Use ↑/↓ to see more options)'; + +const UPSTREAM_HINT = '(Move up and down to reveal more choices)'; + +interface Paginator { + paginate(output: string, active: number, pageSize?: number): string; +} + +export type PaginatedClass = new (...params: never[]) => { paginator: Paginator }; + +export function launchPaginated(Prompt: PaginatedClass): PaginatedClass { + return class LaunchPaginated extends Prompt { + constructor(...params: never[]) { + super(...params); + const paginate = this.paginator.paginate.bind(this.paginator); + this.paginator.paginate = (output, active, pageSize) => + paginate(output, active, pageSize).replace(UPSTREAM_HINT, MORE_CHOICES_HINT); + } + }; +} + +export function scrollWindow(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string { + const lines = output.split('\n'); + + if (lines.length <= pageSize) { + return output; + } + + const top = Math.min(Math.max(active - Math.floor(pageSize / 2), 0), lines.length - pageSize); + + return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join( + '\n', + ); +} + export function launchSearchList(SearchList: SearchListClass): SearchListClass { return class LaunchSearchList extends SearchList { constructor(...params: never[]) { super(...params); this.pointer = startingPointer(this.list, this.opt.default); this.opt.validate = refuseUnlisted(this.opt.validate); + this.opt.pageSize = this.opt.pageSize ?? PROMPT_PAGE_SIZE; + this.paginator = { paginate: scrollWindow }; + } + + onDownKey(): void { + this.pointer = Math.min(this.pointer + 1, Math.max(this.filterList.length - 1, 0)); + this.render(); + } + + onUpKey(): void { + this.pointer = Math.max(this.pointer - 1, 0); + this.render(); } getCurrentValue(line?: unknown): unknown { @@ -99,4 +151,8 @@ export function registerSearchList(load: ModuleLoader = utilitiesLoader()): void const inquirer = load('inquirer') as PromptRegistry; inquirer.registerPrompt('search-list', launchSearchList(load('inquirer-search-list') as SearchListClass)); + + for (const type of ['list', 'checkbox']) { + inquirer.registerPrompt(type, launchPaginated(load(`inquirer/lib/prompts/${type}`) as PaginatedClass)); + } } diff --git a/src/environments/environment.update.prompt.test.ts b/src/environments/environment.update.prompt.test.ts index 6716733..311488f 100644 --- a/src/environments/environment.update.prompt.test.ts +++ b/src/environments/environment.update.prompt.test.ts @@ -77,7 +77,7 @@ describe('promptForEnvironmentUpdate', () => { { name: 'Password protection', value: 'passwordProtection' }, ], loop: false, - pageSize: 12, + pageSize: 7, validate: expect.any(Function), }); });