From 9d2cce364f25b0b9f5a398a5430fedf490f19778 Mon Sep 17 00:00:00 2001 From: chouchouji Date: Wed, 3 Sep 2025 16:12:39 +0800 Subject: [PATCH 1/7] feat: support disabled for select and multiselect prompt --- packages/core/src/prompts/multi-select.ts | 23 ++++++++++++++------ packages/core/src/prompts/select.ts | 19 +++++++++++------ packages/core/src/utils/cursor.ts | 17 +++++++++++++++ packages/prompts/src/multi-select.ts | 17 ++++++++++++++- packages/prompts/src/select.ts | 26 +++++++++++++++++++++-- 5 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/utils/cursor.ts diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index 2781910e..9d1f206a 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,13 +1,16 @@ +import { findNextCursor, findPrevCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface MultiSelectOptions +interface MultiSelectOptions extends PromptOptions> { options: T[]; initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; } -export default class MultiSelectPrompt extends Prompt { +export default class MultiSelectPrompt extends Prompt< + T['value'][] +> { options: T[]; cursor = 0; @@ -17,7 +20,7 @@ export default class MultiSelectPrompt extends Prompt< private toggleAll() { const allSelected = this.value !== undefined && this.value.length === this.options.length; - this.value = allSelected ? [] : this.options.map((v) => v.value); + this.value = allSelected ? [] : this.options.filter((v) => !v.disabled).map((v) => v.value); } private toggleInvert() { @@ -25,7 +28,7 @@ export default class MultiSelectPrompt extends Prompt< if (!value) { return; } - const notSelected = this.options.filter((v) => !value.includes(v.value)); + const notSelected = this.options.filter((v) => !v.disabled && !value.includes(v.value)); this.value = notSelected.map((v) => v.value); } @@ -43,11 +46,17 @@ export default class MultiSelectPrompt extends Prompt< super(opts, false); this.options = opts.options; + const disabledOptions = this.options.filter((option) => option.disabled); + if (this.options.length === disabledOptions.length) return; this.value = [...(opts.initialValues ?? [])]; - this.cursor = Math.max( + const cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), 0 ); + this.cursor = this.options[cursor].disabled ? findNextCursor( + cursor, + this.options + ) : cursor; this.on('key', (char) => { if (char === 'a') { this.toggleAll(); @@ -61,11 +70,11 @@ export default class MultiSelectPrompt extends Prompt< switch (key) { case 'left': case 'up': - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + this.cursor = findPrevCursor(this.cursor, this.options); break; case 'down': case 'right': - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + this.cursor = findNextCursor(this.cursor, this.options); break; case 'space': this.toggleValue(); diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index d24928b9..f4af8b0a 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,11 +1,14 @@ +import { findNextCursor, findPrevCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface SelectOptions +interface SelectOptions extends PromptOptions> { options: T[]; initialValue?: T['value']; } -export default class SelectPrompt extends Prompt { +export default class SelectPrompt extends Prompt< + T['value'] +> { options: T[]; cursor = 0; @@ -21,19 +24,23 @@ export default class SelectPrompt extends Prompt value === opts.initialValue); - if (this.cursor === -1) this.cursor = 0; + const disabledOptions = this.options.filter((option) => option.disabled); + if (this.options.length === disabledOptions.length) return; + + const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); + const cursor = initialCursor === -1 ? 0 : initialCursor + this.cursor = this.options[cursor].disabled ? findNextCursor(cursor, this.options) : cursor; this.changeValue(); this.on('cursor', (key) => { switch (key) { case 'left': case 'up': - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + this.cursor = findPrevCursor(this.cursor, this.options); break; case 'down': case 'right': - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + this.cursor = findNextCursor(this.cursor, this.options); break; } this.changeValue(); diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts new file mode 100644 index 00000000..6a618b99 --- /dev/null +++ b/packages/core/src/utils/cursor.ts @@ -0,0 +1,17 @@ +export function findPrevCursor(cursor: number, options: T[]) { + const prevCursor = cursor === 0 ? options.length - 1 : cursor - 1; + const prevOption = options[prevCursor]; + if (prevOption.disabled) { + return findPrevCursor(prevCursor, options); + } + return prevCursor; +} + +export function findNextCursor(cursor: number, options: T[]) { + const nextCursor = cursor === options.length - 1 ? 0 : cursor + 1; + const nextOption = options[nextCursor]; + if (nextOption.disabled) { + return findNextCursor(nextCursor, options); + } + return nextCursor; +} diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 1d928a7e..20f710dd 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -23,9 +23,21 @@ export interface MultiSelectOptions extends CommonOptions { export const multiselect = (opts: MultiSelectOptions) => { const opt = ( option: Option, - state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled' + state: + | 'inactive' + | 'active' + | 'selected' + | 'active-selected' + | 'submitted' + | 'cancelled' + | 'disabled' ) => { const label = option.label ?? String(option.value); + if (state === 'disabled') { + return `${color.black(S_CHECKBOX_SELECTED)} ${color.dim(label)}${ + option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' + }`; + } if (state === 'active') { return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label}${ option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' @@ -74,6 +86,9 @@ export const multiselect = (opts: MultiSelectOptions) => { const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { + if (option.disabled) { + return opt(option, 'disabled'); + } const selected = value.includes(option.value); if (active && selected) { return opt(option, 'active-selected'); diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index 1dafaea3..b51ef85a 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -31,6 +31,13 @@ export type Option = Value extends Primitive * By default, no `hint` is displayed. */ hint?: string; + /** + * Whether this option is disabled. + * Disabled options are visible but cannot be selected. + * + * By default, options are not disabled. + */ + disabled?: boolean; } : { /** @@ -48,6 +55,13 @@ export type Option = Value extends Primitive * By default, no `hint` is displayed. */ hint?: string; + /** + * Whether this option is disabled. + * Disabled options are visible but cannot be selected. + * + * By default, options are not disabled. + */ + disabled?: boolean; }; export interface SelectOptions extends CommonOptions { @@ -58,9 +72,16 @@ export interface SelectOptions extends CommonOptions { } export const select = (opts: SelectOptions) => { - const opt = (option: Option, state: 'inactive' | 'active' | 'selected' | 'cancelled') => { + const opt = ( + option: Option, + state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' + ) => { const label = option.label ?? String(option.value); switch (state) { + case 'disabled': + return `${color.black(S_RADIO_ACTIVE)} ${color.dim(label)}${ + option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' + }`; case 'selected': return `${color.dim(label)}`; case 'active': @@ -97,7 +118,8 @@ export const select = (opts: SelectOptions) => { cursor: this.cursor, options: this.options, maxItems: opts.maxItems, - style: (item, active) => opt(item, active ? 'active' : 'inactive'), + style: (item, active) => + opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } } From 6eed35df45f9ee918c4bc47f7b5306a638f38cc8 Mon Sep 17 00:00:00 2001 From: chouchouji Date: Wed, 3 Sep 2025 17:08:46 +0800 Subject: [PATCH 2/7] docs: update readme --- packages/prompts/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/prompts/README.md b/packages/prompts/README.md index ba474b6a..5cbe88e4 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -86,7 +86,7 @@ const projectType = await select({ message: 'Pick a project type.', options: [ { value: 'ts', label: 'TypeScript' }, - { value: 'js', label: 'JavaScript' }, + { value: 'js', label: 'JavaScript', disabled: true }, { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' }, ], }); @@ -103,7 +103,7 @@ const additionalTools = await multiselect({ message: 'Select additional tools.', options: [ { value: 'eslint', label: 'ESLint', hint: 'recommended' }, - { value: 'prettier', label: 'Prettier' }, + { value: 'prettier', label: 'Prettier', disabled: true }, { value: 'gh-action', label: 'GitHub Action' }, ], required: false, From ef8ed7e5976895ba7780f0e3abe2a615a0726e99 Mon Sep 17 00:00:00 2001 From: chouchouji Date: Wed, 3 Sep 2025 19:50:42 +0800 Subject: [PATCH 3/7] refactor: optimize code --- packages/core/src/prompts/multi-select.ts | 11 ++++++----- packages/core/src/prompts/select.ts | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index 9d1f206a..1264170d 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -13,14 +13,15 @@ export default class MultiSelectPrompt { options: T[]; cursor = 0; + #enabledOptions: T[] = []; private get _value(): T['value'] { return this.options[this.cursor].value; } private toggleAll() { - const allSelected = this.value !== undefined && this.value.length === this.options.length; - this.value = allSelected ? [] : this.options.filter((v) => !v.disabled).map((v) => v.value); + const allSelected = this.value !== undefined && this.value.length === this.#enabledOptions.length; + this.value = allSelected ? [] : this.#enabledOptions.map((v) => v.value); } private toggleInvert() { @@ -28,7 +29,7 @@ export default class MultiSelectPrompt !v.disabled && !value.includes(v.value)); + const notSelected = this.#enabledOptions.filter((v) => !value.includes(v.value)); this.value = notSelected.map((v) => v.value); } @@ -46,8 +47,8 @@ export default class MultiSelectPrompt option.disabled); - if (this.options.length === disabledOptions.length) return; + this.#enabledOptions = this.options.filter((option) => !option.disabled); + if (this.#enabledOptions.length === 0) return; this.value = [...(opts.initialValues ?? [])]; const cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index f4af8b0a..902d923b 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -11,6 +11,7 @@ export default class SelectPrompt > { options: T[]; cursor = 0; + #enabledOptions: T[] = []; private get _selectedValue() { return this.options[this.cursor]; @@ -24,8 +25,8 @@ export default class SelectPrompt super(opts, false); this.options = opts.options; - const disabledOptions = this.options.filter((option) => option.disabled); - if (this.options.length === disabledOptions.length) return; + this.#enabledOptions = this.options.filter((option) => !option.disabled); + if (this.#enabledOptions.length === 0) return; const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); const cursor = initialCursor === -1 ? 0 : initialCursor From df2150609624cc2b6f46c85a0814832fdf1ecc9e Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:44:48 +0100 Subject: [PATCH 4/7] feat: use single cursor function --- packages/core/src/prompts/multi-select.ts | 36 ++++++++++++----------- packages/core/src/prompts/select.ts | 13 ++++---- packages/core/src/utils/cursor.ts | 27 ++++++++--------- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index 1264170d..7f4643ba 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,27 +1,34 @@ -import { findNextCursor, findPrevCursor } from '../utils/cursor.js'; +import { findCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface MultiSelectOptions +interface OptionLike { + value: any; + disabled?: boolean; +} + +interface MultiSelectOptions extends PromptOptions> { options: T[]; initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; } -export default class MultiSelectPrompt extends Prompt< - T['value'][] -> { +export default class MultiSelectPrompt extends Prompt { options: T[]; cursor = 0; - #enabledOptions: T[] = []; private get _value(): T['value'] { return this.options[this.cursor].value; } + private get _enabledOptions(): T[] { + return this.options.filter((option) => option.disabled !== true); + } + private toggleAll() { - const allSelected = this.value !== undefined && this.value.length === this.#enabledOptions.length; - this.value = allSelected ? [] : this.#enabledOptions.map((v) => v.value); + const enabledOptions = this._enabledOptions; + const allSelected = this.value !== undefined && this.value.length === enabledOptions.length; + this.value = allSelected ? [] : enabledOptions.map((v) => v.value); } private toggleInvert() { @@ -29,7 +36,7 @@ export default class MultiSelectPrompt !value.includes(v.value)); + const notSelected = this._enabledOptions.filter((v) => !value.includes(v.value)); this.value = notSelected.map((v) => v.value); } @@ -47,17 +54,12 @@ export default class MultiSelectPrompt !option.disabled); - if (this.#enabledOptions.length === 0) return; this.value = [...(opts.initialValues ?? [])]; const cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), 0 ); - this.cursor = this.options[cursor].disabled ? findNextCursor( - cursor, - this.options - ) : cursor; + this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; this.on('key', (char) => { if (char === 'a') { this.toggleAll(); @@ -71,11 +73,11 @@ export default class MultiSelectPrompt(this.cursor, this.options); + this.cursor = findCursor(this.cursor, -1, this.options); break; case 'down': case 'right': - this.cursor = findNextCursor(this.cursor, this.options); + this.cursor = findCursor(this.cursor, 1, this.options); break; case 'space': this.toggleValue(); diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 902d923b..b169e50e 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,4 +1,4 @@ -import { findNextCursor, findPrevCursor } from '../utils/cursor.js'; +import { findCursor } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface SelectOptions @@ -11,7 +11,6 @@ export default class SelectPrompt > { options: T[]; cursor = 0; - #enabledOptions: T[] = []; private get _selectedValue() { return this.options[this.cursor]; @@ -25,23 +24,21 @@ export default class SelectPrompt super(opts, false); this.options = opts.options; - this.#enabledOptions = this.options.filter((option) => !option.disabled); - if (this.#enabledOptions.length === 0) return; const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); - const cursor = initialCursor === -1 ? 0 : initialCursor - this.cursor = this.options[cursor].disabled ? findNextCursor(cursor, this.options) : cursor; + const cursor = initialCursor === -1 ? 0 : initialCursor; + this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; this.changeValue(); this.on('cursor', (key) => { switch (key) { case 'left': case 'up': - this.cursor = findPrevCursor(this.cursor, this.options); + this.cursor = findCursor(this.cursor, -1, this.options); break; case 'down': case 'right': - this.cursor = findNextCursor(this.cursor, this.options); + this.cursor = findCursor(this.cursor, 1, this.options); break; } this.changeValue(); diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 6a618b99..22d69359 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -1,17 +1,14 @@ -export function findPrevCursor(cursor: number, options: T[]) { - const prevCursor = cursor === 0 ? options.length - 1 : cursor - 1; - const prevOption = options[prevCursor]; - if (prevOption.disabled) { - return findPrevCursor(prevCursor, options); +export function findCursor( + cursor: number, + delta: number, + options: T[] +) { + const newCursor = cursor + delta; + const maxCursor = Math.max(options.length - 1, 0); + const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; + const newOption = options[clampedCursor]; + if (newOption.disabled) { + return findCursor(clampedCursor, delta + (delta < 0 ? -1 : 1), options); } - return prevCursor; -} - -export function findNextCursor(cursor: number, options: T[]) { - const nextCursor = cursor === options.length - 1 ? 0 : cursor + 1; - const nextOption = options[nextCursor]; - if (nextOption.disabled) { - return findNextCursor(nextCursor, options); - } - return nextCursor; + return clampedCursor; } From d9f86eb2edafc7a9fb29718b35306e50670dc2b1 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:58:05 +0100 Subject: [PATCH 5/7] fix: change find cursor to use +/- 1 --- packages/core/src/utils/cursor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 22d69359..6028f647 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -8,7 +8,7 @@ export function findCursor( const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; const newOption = options[clampedCursor]; if (newOption.disabled) { - return findCursor(clampedCursor, delta + (delta < 0 ? -1 : 1), options); + return findCursor(clampedCursor, (delta < 0 ? -1 : 1), options); } return clampedCursor; } From c0273d67770d26f96d03adcb4bc91d6942adb7fb Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:22:51 +0100 Subject: [PATCH 6/7] test: add disabled select tests --- packages/core/src/utils/cursor.ts | 2 +- .../core/test/prompts/multi-select.test.ts | 79 +++++++++++++++++++ packages/core/test/prompts/select.test.ts | 38 +++++++++ packages/prompts/src/multi-select.ts | 16 ++-- packages/prompts/src/select.ts | 10 ++- .../__snapshots__/multi-select.test.ts.snap | 52 ++++++++++++ .../test/__snapshots__/select.test.ts.snap | 42 ++++++++++ packages/prompts/test/multi-select.test.ts | 21 +++++ packages/prompts/test/select.test.ts | 20 +++++ 9 files changed, 269 insertions(+), 11 deletions(-) diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 6028f647..1e935aa8 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -8,7 +8,7 @@ export function findCursor( const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; const newOption = options[clampedCursor]; if (newOption.disabled) { - return findCursor(clampedCursor, (delta < 0 ? -1 : 1), options); + return findCursor(clampedCursor, delta < 0 ? -1 : 1, options); } return clampedCursor; } diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/test/prompts/multi-select.test.ts index b1ee7432..99695793 100644 --- a/packages/core/test/prompts/multi-select.test.ts +++ b/packages/core/test/prompts/multi-select.test.ts @@ -130,5 +130,84 @@ describe('MultiSelectPrompt', () => { input.emit('keypress', 'i', { name: 'i' }); expect(instance.value).toEqual(['foo']); }); + + test('disabled options are skipped', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(0); + }); + + test('initial cursorAt on disabled option', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + cursorAt: 'bar', + }); + instance.prompt(); + + expect(instance.cursor).to.equal(2); + }); + }); + + describe('toggleAll', () => { + test('selects all enabled options', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual(['foo', 'baz']); + }); + + test('unselects all enabled options if all selected', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + initialValues: ['foo', 'baz'], + }); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual([]); + }); + }); + + describe('toggleInvert', () => { + test('inverts selection of enabled options', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { value: 'bar', disabled: true }, + { value: 'baz' }, + { value: 'qux' }, + ], + initialValues: ['foo', 'baz'], + }); + instance.prompt(); + + input.emit('keypress', 'i', { name: 'i' }); + expect(instance.value).toEqual(['qux']); + }); }); }); diff --git a/packages/core/test/prompts/select.test.ts b/packages/core/test/prompts/select.test.ts index 996b4f56..a3583061 100644 --- a/packages/core/test/prompts/select.test.ts +++ b/packages/core/test/prompts/select.test.ts @@ -100,5 +100,43 @@ describe('SelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(1); }); + + test('cursor skips disabled options (down)', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + }); + + test('cursor skips disabled options (up)', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + initialValue: 'baz', + options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(0); + }); + + test('cursor skips initial disabled option', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }], + }); + instance.prompt(); + expect(instance.cursor).to.equal(1); + }); }); }); diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 20f710dd..75ca9045 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -34,8 +34,8 @@ export const multiselect = (opts: MultiSelectOptions) => { ) => { const label = option.label ?? String(option.value); if (state === 'disabled') { - return `${color.black(S_CHECKBOX_SELECTED)} ${color.dim(label)}${ - option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' + return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.gray(label)}${ + option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' }`; } if (state === 'active') { @@ -118,28 +118,32 @@ export const multiselect = (opts: MultiSelectOptions) => { }`; } case 'error': { + const prefix = `${color.yellow(S_BAR)} `; const footer = this.error .split('\n') .map((ln, i) => i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` ) .join('\n'); - return `${title + color.yellow(S_BAR)} ${limitOptions({ + return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + columnPadding: prefix.length, style: styleOption, - }).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + }).join(`\n${prefix}`)}\n${footer}\n`; } default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + const prefix = `${color.cyan(S_BAR)} `; + return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, + columnPadding: prefix.length, style: styleOption, - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; } } }, diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index b51ef85a..b091161c 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -79,8 +79,8 @@ export const select = (opts: SelectOptions) => { const label = option.label ?? String(option.value); switch (state) { case 'disabled': - return `${color.black(S_RADIO_ACTIVE)} ${color.dim(label)}${ - option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' + return `${color.gray(S_RADIO_INACTIVE)} ${color.gray(label)}${ + option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' }`; case 'selected': return `${color.dim(label)}`; @@ -113,14 +113,16 @@ export const select = (opts: SelectOptions) => { 'cancelled' )}\n${color.gray(S_BAR)}`; default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + const prefix = `${color.cyan(S_BAR)} `; + return `${title}${prefix}${limitOptions({ output: opts.output, cursor: this.cursor, options: this.options, maxItems: opts.maxItems, + columnPadding: prefix.length, style: (item, active) => opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; } } }, diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index bdb20f98..a0028fd4 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -240,6 +240,32 @@ exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` ] `; +exports[`multiselect (isCI = false) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +│ ◻ opt2 (Hint 2) +└ +", + "", + "", + "", + "│ ◼ opt1", + "", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + exports[`multiselect (isCI = false) > renders message 1`] = ` [ "", @@ -850,6 +876,32 @@ exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` ] `; +exports[`multiselect (isCI = true) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ opt0 +│ ◻ opt1 +│ ◻ opt2 (Hint 2) +└ +", + "", + "", + "", + "│ ◼ opt1", + "", + "", + "", + "", + "◇ foo +│ opt1", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > renders message 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 05d3d2d2..bd8238d0 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -63,6 +63,27 @@ exports[`select (isCI = false) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = false) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 (Hint 2) +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = false) > renders option hints 1`] = ` [ "", @@ -220,6 +241,27 @@ exports[`select (isCI = true) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = true) > renders disabled options 1`] = ` +[ + "", + "│ +◆ foo +│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 (Hint 2) +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = true) > renders option hints 1`] = ` [ "", diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/test/multi-select.test.ts index cd524001..95412ade 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/test/multi-select.test.ts @@ -315,4 +315,25 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('renders disabled options', async () => { + const result = prompts.multiselect({ + message: 'foo', + options: [ + { value: 'opt0', disabled: true }, + { value: 'opt1' }, + { value: 'opt2', disabled: true, hint: 'Hint 2' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['opt1']); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index a9c93fc1..24cbc726 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -145,4 +145,24 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('renders disabled options', async () => { + const result = prompts.select({ + message: 'foo', + options: [ + { value: 'opt0', label: 'Option 0', disabled: true }, + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2', disabled: true, hint: 'Hint 2' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt1'); + expect(output.buffer).toMatchSnapshot(); + }); }); From 4f0a10e90b2578a1ae1c94fca90454d481023eef Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:23:58 +0100 Subject: [PATCH 7/7] chore: add changeset --- .changeset/itchy-coins-cry.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/itchy-coins-cry.md diff --git a/.changeset/itchy-coins-cry.md b/.changeset/itchy-coins-cry.md new file mode 100644 index 00000000..5e158684 --- /dev/null +++ b/.changeset/itchy-coins-cry.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Allow disabled options in multi-select and select prompts.