From 4b43d104099a662a2319b5d5278d39708705590b Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 29 May 2026 22:06:02 +0300 Subject: [PATCH 1/2] feat: add separator option support to select, multiselect, and groupMultiselect Support { type: 'separator', label?: string } entries in option arrays for select, multiselect, and groupMultiselect prompts. Separators are rendered as dim text but skipped by the cursor and excluded from values. Core changes: - Add isSeparator() utility and SeparatorOption type to cursor.ts - Update findCursor() to skip separators alongside disabled options - Update SelectPrompt and MultiSelectPrompt to skip separators during cursor initialization and navigation, and exclude them from toggleAll/toggleInvert - Refactor GroupMultiSelectPrompt cursor navigation into #moveCursor that skips separators and non-selectable groups Prompts changes: - Update Option type to be a union with SeparatorOption - Add separator rendering in select, multiselect, and groupMultiselect - Separators render as dim text without selection UI Resolves #2, #8 from clack-fixes.md. --- packages/core/src/index.ts | 3 +- .../core/src/prompts/group-multiselect.ts | 36 +++-- packages/core/src/prompts/multi-select.ts | 9 +- packages/core/src/prompts/select.ts | 10 +- packages/core/src/utils/cursor.ts | 17 ++- packages/core/src/utils/index.ts | 1 + .../core/test/prompts/multi-select.test.ts | 55 ++++++++ packages/core/test/prompts/select.test.ts | 34 +++++ packages/core/test/utils/cursor.test.ts | 39 +++++- packages/prompts/src/group-multi-select.ts | 16 ++- packages/prompts/src/multi-select.ts | 12 +- packages/prompts/src/select.ts | 130 ++++++++++-------- 12 files changed, 277 insertions(+), 85 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4383a3df..ea175e5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,8 +21,9 @@ export { default as SelectKeyPrompt } from './prompts/select-key.js'; export type { TextOptions } from './prompts/text.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; -export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js'; +export { block, getColumns, getRows, isCancel, isSeparator, wrapTextWithPrefix } from './utils/index.js'; export type { ClackSettings } from './utils/settings.js'; +export type { SeparatorOption } from './utils/cursor.js'; export { settings, updateSettings } from './utils/settings.js'; export type { Validate } from './utils/validation.js'; export { runValidation } from './utils/validation.js'; diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index c189a919..1513962d 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -1,3 +1,4 @@ +import { isSeparator } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; export interface GroupMultiSelectOptions @@ -50,6 +51,21 @@ export default class GroupMultiSelectPrompt extends Pr } } + #moveCursor(delta: number) { + const len = this.options.length; + let next = this.cursor; + for (let i = 0; i < len; i++) { + next = next + delta; + if (next < 0) next = len - 1; + if (next >= len) next = 0; + const opt = this.options[next]; + if (isSeparator(opt)) continue; + if (opt.group === true && !this.#selectableGroups) continue; + break; + } + this.cursor = next; + } + constructor(opts: GroupMultiSelectOptions) { super(opts, false); const { options } = opts; @@ -60,30 +76,20 @@ export default class GroupMultiSelectPrompt extends Pr ]) as any; this.value = [...(opts.initialValues ?? [])]; this.cursor = Math.max( - this.options.findIndex(({ value }) => value === opts.cursorAt), + this.options.findIndex((opt) => !isSeparator(opt) && opt.value === opts.cursorAt), this.#selectableGroups ? 0 : 1 ); this.on('cursor', (key) => { switch (key) { case 'left': - case 'up': { - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; - const currentIsGroup = this.options[this.cursor]?.group === true; - if (!this.#selectableGroups && currentIsGroup) { - this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; - } + case 'up': + this.#moveCursor(-1); break; - } case 'down': - case 'right': { - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; - const currentIsGroup = this.options[this.cursor]?.group === true; - if (!this.#selectableGroups && currentIsGroup) { - this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; - } + case 'right': + this.#moveCursor(1); break; - } case 'space': this.toggleValue(); break; diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index f5b04254..db823519 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,9 +1,10 @@ -import { findCursor } from '../utils/cursor.js'; +import { findCursor, isSeparator } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface OptionLike { value: any; disabled?: boolean; + type?: string; } export interface MultiSelectOptions @@ -22,7 +23,7 @@ export default class MultiSelectPrompt extends Prompt option.disabled !== true); + return this.options.filter((option) => option.disabled !== true && !isSeparator(option)); } private toggleAll() { @@ -56,10 +57,10 @@ export default class MultiSelectPrompt extends Prompt value === opts.cursorAt), + this.options.findIndex((opt) => !isSeparator(opt) && opt.value === opts.cursorAt), 0 ); - this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; + this.cursor = this.options[cursor].disabled || isSeparator(this.options[cursor]) ? findCursor(cursor, 1, this.options) : cursor; this.on('key', (_char, key) => { if (key.name === 'a') { this.toggleAll(); diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 99ed5df9..ce747c58 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,12 +1,12 @@ -import { findCursor } from '../utils/cursor.js'; +import { findCursor, isSeparator } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; -export interface SelectOptions +export 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[]; @@ -25,9 +25,9 @@ export default class SelectPrompt this.options = opts.options; - const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue); + const initialCursor = this.options.findIndex((opt) => !isSeparator(opt) && opt.value === opts.initialValue); const cursor = initialCursor === -1 ? 0 : initialCursor; - this.cursor = this.options[cursor].disabled ? findCursor(cursor, 1, this.options) : cursor; + this.cursor = this.options[cursor].disabled || isSeparator(this.options[cursor]) ? findCursor(cursor, 1, this.options) : cursor; this.changeValue(); this.on('cursor', (key) => { diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 75df1ac3..cec76e84 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -1,9 +1,22 @@ +export interface SeparatorOption { + type: 'separator'; + label?: string; +} + +export function isSeparator(option: unknown): option is SeparatorOption { + return typeof option === 'object' && option !== null && (option as any).type === 'separator'; +} + +function isSkippable(option: T): boolean { + return option.disabled === true || isSeparator(option); +} + export function findCursor( cursor: number, delta: number, options: T[] ) { - const hasEnabledOptions = options.some((opt) => !opt.disabled); + const hasEnabledOptions = options.some((opt) => !isSkippable(opt)); if (!hasEnabledOptions) { return cursor; } @@ -11,7 +24,7 @@ export function findCursor( const maxCursor = Math.max(options.length - 1, 0); const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor; const newOption = options[clampedCursor]; - if (newOption.disabled) { + if (isSkippable(newOption)) { return findCursor(clampedCursor, delta < 0 ? -1 : 1, options); } return clampedCursor; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 84ddd611..76faa088 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -9,6 +9,7 @@ import { isActionKey } from './settings.js'; export * from './settings.js'; export * from './string.js'; +export { isSeparator, type SeparatorOption } from './cursor.js'; const isWindows = globalThis.process.platform.startsWith('win'); diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/test/prompts/multi-select.test.ts index 99695793..272acd6b 100644 --- a/packages/core/test/prompts/multi-select.test.ts +++ b/packages/core/test/prompts/multi-select.test.ts @@ -147,6 +147,61 @@ describe('MultiSelectPrompt', () => { expect(instance.cursor).to.equal(0); }); + test('separator options are skipped by cursor', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { type: 'separator' as const, label: '---' }, + { value: 'bar' }, + ], + }); + 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('toggleAll excludes separators', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { type: 'separator' as const, label: '---' }, + { value: 'bar' }, + ], + }); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual(['foo', 'bar']); + }); + + test('toggleInvert excludes separators', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { type: 'separator' as const, label: '---' }, + { value: 'bar' }, + ], + initialValues: ['foo'], + }); + instance.prompt(); + + input.emit('keypress', 'i', { name: 'i' }); + expect(instance.value).toEqual(['bar']); + }); + test('initial cursorAt on disabled option', () => { const instance = new MultiSelectPrompt({ input, diff --git a/packages/core/test/prompts/select.test.ts b/packages/core/test/prompts/select.test.ts index a3583061..af85eebc 100644 --- a/packages/core/test/prompts/select.test.ts +++ b/packages/core/test/prompts/select.test.ts @@ -138,5 +138,39 @@ describe('SelectPrompt', () => { instance.prompt(); expect(instance.cursor).to.equal(1); }); + + test('cursor skips separator options', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { value: 'foo' }, + { type: 'separator' as const, label: '---' }, + { value: 'bar' }, + ], + }); + 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('cursor skips initial separator option', () => { + const instance = new SelectPrompt({ + input, + output, + render: () => 'foo', + options: [ + { type: 'separator' as const, label: '---' }, + { value: 'foo' }, + { value: 'bar' }, + ], + }); + instance.prompt(); + expect(instance.cursor).to.equal(1); + }); }); }); diff --git a/packages/core/test/utils/cursor.test.ts b/packages/core/test/utils/cursor.test.ts index 458d231e..0f0b72fa 100644 --- a/packages/core/test/utils/cursor.test.ts +++ b/packages/core/test/utils/cursor.test.ts @@ -1,5 +1,20 @@ import { describe, expect, test } from 'vitest'; -import { findCursor, findTextCursor } from '../../src/utils/cursor.js'; +import { findCursor, findTextCursor, isSeparator } from '../../src/utils/cursor.js'; + +describe('isSeparator', () => { + test('returns true for separator options', () => { + expect(isSeparator({ type: 'separator' })).toBe(true); + expect(isSeparator({ type: 'separator', label: 'Header' })).toBe(true); + }); + + test('returns false for non-separator options', () => { + expect(isSeparator({ value: 'foo' })).toBe(false); + expect(isSeparator({ value: 'foo', disabled: true })).toBe(false); + expect(isSeparator({})).toBe(false); + expect(isSeparator(null)).toBe(false); + expect(isSeparator(undefined)).toBe(false); + }); +}); describe('findCursor', () => { test('returns the same cursor if all options are disabled', () => { @@ -24,6 +39,28 @@ describe('findCursor', () => { expect(findCursor(0, 1, options)).toBe(0); expect(findCursor(0, -1, options)).toBe(0); }); + + test('skips separator options', () => { + const options = [{ value: 'a' }, { type: 'separator' as const, label: '---' }, { value: 'b' }]; + expect(findCursor(0, 1, options)).toBe(2); + expect(findCursor(2, -1, options)).toBe(0); + }); + + test('returns same cursor if all options are separators or disabled', () => { + const options = [{ type: 'separator' as const }, { disabled: true }]; + expect(findCursor(0, 1, options)).toBe(0); + }); + + test('skips mix of separators and disabled', () => { + const options = [ + { value: 'a' }, + { type: 'separator' as const }, + { disabled: true }, + { value: 'b' }, + ]; + expect(findCursor(0, 1, options)).toBe(3); + expect(findCursor(3, -1, options)).toBe(0); + }); }); describe('findTextCursor', () => { diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 4ddc3d3f..1a14494c 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -10,7 +10,10 @@ import { symbol, } from './common.js'; import { limitOptions } from './limit-options.js'; -import type { Option } from './select.js'; +import type { Option, SeparatorOption } from './select.js'; + +const isSeparatorOption = (opt: Option): opt is SeparatorOption => + 'type' in opt && opt.type === 'separator'; /** * Options for the {@link groupMultiselect} prompt. @@ -99,9 +102,15 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => | 'group-active' | 'group-active-selected' | 'submitted' - | 'cancelled', + | 'cancelled' + | 'separator', options: (Option & { group: string | boolean })[] = [] ) => { + if (state === 'separator') { + const isItem = typeof option.group === 'string'; + const prefix = isItem ? (selectableGroups ? `${S_BAR} ` : ' ') : ''; + return `${prefix}${styleText('dim', option.label ?? '────────────')}`; + } const label = option.label ?? String(option.value); const isItem = typeof option.group === 'string'; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); @@ -222,6 +231,9 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => active: boolean ) => { const options = this.options; + if (isSeparatorOption(option)) { + return opt(option, 'separator', options); + } const selected = value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`)); diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 28b27aab..1def83f1 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -11,7 +11,10 @@ import { symbolBar, } from './common.js'; import { limitOptions } from './limit-options.js'; -import type { Option } from './select.js'; +import type { Option, SeparatorOption } from './select.js'; + +const isSeparatorOption = (opt: Option): opt is SeparatorOption => + 'type' in opt && opt.type === 'separator'; export interface MultiSelectOptions extends CommonOptions { message: string; @@ -39,7 +42,11 @@ export const multiselect = (opts: MultiSelectOptions) => { | 'submitted' | 'cancelled' | 'disabled' + | 'separator' ) => { + if (state === 'separator') { + return styleText('dim', option.label ?? '────────────'); + } const label = option.label ?? String(option.value); if (state === 'disabled') { return `${styleText('gray', S_CHECKBOX_INACTIVE)} ${computeLabel(label, (str) => styleText(['strikethrough', 'gray'], str))}${ @@ -104,6 +111,9 @@ export const multiselect = (opts: MultiSelectOptions) => { const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { + if (isSeparatorOption(option)) { + return opt(option, 'separator'); + } if (option.disabled) { return opt(option, 'disabled'); } diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index a3407cdb..dd9aaed3 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -13,57 +13,71 @@ import { limitOptions } from './limit-options.js'; type Primitive = Readonly; -export type Option = Value extends Primitive - ? { - /** - * Internal data for this option. - */ - value: Value; - /** - * The optional, user-facing text for this option. - * - * By default, the `value` is converted to a string. - */ - label?: string; - /** - * An optional hint to display to the user when - * this option might be selected. - * - * 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; - } - : { - /** - * Internal data for this option. - */ - value: Value; - /** - * Required. The user-facing text for this option. - */ - label: string; - /** - * An optional hint to display to the user when - * this option might be selected. - * - * 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 type SeparatorOption = { + /** + * Marks this entry as a separator. Separators are rendered but + * skipped by the cursor and excluded from the result. + */ + type: 'separator'; + /** + * The text to display for this separator. + */ + label?: string; +}; + +export type Option = + | SeparatorOption + | (Value extends Primitive + ? { + /** + * Internal data for this option. + */ + value: Value; + /** + * The optional, user-facing text for this option. + * + * By default, the `value` is converted to a string. + */ + label?: string; + /** + * An optional hint to display to the user when + * this option might be selected. + * + * 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; + } + : { + /** + * Internal data for this option. + */ + value: Value; + /** + * Required. The user-facing text for this option. + */ + label: string; + /** + * An optional hint to display to the user when + * this option might be selected. + * + * 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 { message: string; @@ -82,11 +96,17 @@ const computeLabel = (label: string, format: (text: string) => string) => { .join('\n'); }; +const isSeparatorOption = (opt: Option): opt is SeparatorOption => + 'type' in opt && opt.type === 'separator'; + export const select = (opts: SelectOptions) => { const opt = ( option: Option, - state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' + state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled' | 'separator' ) => { + if (state === 'separator') { + return styleText('dim', option.label ?? '────────────'); + } const label = option.label ?? String(option.value); switch (state) { case 'disabled': @@ -156,8 +176,10 @@ export const select = (opts: SelectOptions) => { maxItems: opts.maxItems, columnPadding: prefix.length, rowPadding: titleLineCount + footerLineCount, - style: (item, active) => - opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), + style: (item, active) => + isSeparatorOption(item) + ? opt(item, 'separator') + : opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), }).join(`\n${prefix}`)}\n${prefixEnd}\n`; } } From b8048720e2ceec1433a740f77c431b23934e2888 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 30 May 2026 20:07:11 +0300 Subject: [PATCH 2/2] fix(prompts): harden separator support in group-multiselect and multi-select - Guard toggleValue against separators in GroupMultiSelectPrompt and MultiSelectPrompt - Fix initial cursor placement to skip separators via #findNextSelectable - Prevent separators from receiving group metadata during option flattening - Add hasSelectable guard to prevent cursor landing on unselectable items - Widen options type to include SeparatorOption union, removing as any cast - Deduplicate isSeparatorOption by using shared isSeparator from @clack/core - Adjust limitOptions to account for separators in computedMaxItems - Add tests for GroupMultiSelectPrompt separator behavior --- .../core/src/prompts/group-multiselect.ts | 49 +++-- packages/core/src/prompts/multi-select.ts | 3 + .../test/prompts/group-multi-select.test.ts | 199 ++++++++++++++++++ packages/prompts/src/group-multi-select.ts | 12 +- packages/prompts/src/limit-options.ts | 6 +- packages/prompts/src/multi-select.ts | 9 +- packages/prompts/src/select.ts | 7 +- 7 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 packages/core/test/prompts/group-multi-select.test.ts diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index 1513962d..72f3c893 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -1,4 +1,4 @@ -import { isSeparator } from '../utils/cursor.js'; +import { type SeparatorOption, isSeparator } from '../utils/cursor.js'; import Prompt, { type PromptOptions } from './prompt.js'; export interface GroupMultiSelectOptions @@ -10,12 +10,12 @@ export interface GroupMultiSelectOptions selectableGroups?: boolean; } export default class GroupMultiSelectPrompt extends Prompt { - options: (T & { group: string | boolean })[]; + options: ((T & { group: string | boolean }) | SeparatorOption)[]; cursor = 0; #selectableGroups: boolean; - getGroupItems(group: string): T[] { - return this.options.filter((o) => o.group === group); + getGroupItems(group: string): (T & { group: string })[] { + return this.options.filter((o): o is T & { group: string } => !isSeparator(o) && o.group === group); } isGroupSelected(group: string) { @@ -32,6 +32,9 @@ export default class GroupMultiSelectPrompt extends Pr if (this.value === undefined) { this.value = []; } + if (isSeparator(item)) { + return; + } if (item.group === true) { const group = item.value; const groupedItems = this.getGroupItems(group); @@ -51,19 +54,30 @@ export default class GroupMultiSelectPrompt extends Pr } } - #moveCursor(delta: number) { + #findNextSelectable(start: number, delta: number): number { const len = this.options.length; - let next = this.cursor; + const hasSelectable = this.options.some( + (opt) => !isSeparator(opt) && (this.#selectableGroups || opt.group !== true) + ); + if (!hasSelectable) return this.cursor; + let next = start; for (let i = 0; i < len; i++) { + if (!isSeparator(this.options[next]) && (this.#selectableGroups || this.options[next].group !== true)) { + return next; + } next = next + delta; if (next < 0) next = len - 1; if (next >= len) next = 0; - const opt = this.options[next]; - if (isSeparator(opt)) continue; - if (opt.group === true && !this.#selectableGroups) continue; - break; } - this.cursor = next; + return start; + } + + #moveCursor(delta: number) { + const len = this.options.length; + let next = this.cursor + delta; + if (next < 0) next = len - 1; + if (next >= len) next = 0; + this.cursor = this.#findNextSelectable(next, delta); } constructor(opts: GroupMultiSelectOptions) { @@ -71,14 +85,19 @@ export default class GroupMultiSelectPrompt extends Pr const { options } = opts; this.#selectableGroups = opts.selectableGroups !== false; this.options = Object.entries(options).flatMap(([key, option]) => [ - { value: key, group: true, label: key }, - ...option.map((opt) => ({ ...opt, group: key })), - ]) as any; + { value: key, group: true as const, label: key }, + ...option.map((opt): (T & { group: string }) | SeparatorOption => + isSeparator(opt) ? { ...opt } : { ...opt, group: key } + ), + ]); this.value = [...(opts.initialValues ?? [])]; - this.cursor = Math.max( + const initialCursor = Math.max( this.options.findIndex((opt) => !isSeparator(opt) && opt.value === opts.cursorAt), this.#selectableGroups ? 0 : 1 ); + this.cursor = isSeparator(this.options[initialCursor]) + ? this.#findNextSelectable(initialCursor, 1) + : initialCursor; this.on('cursor', (key) => { switch (key) { diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index db823519..85c2cb7f 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -45,6 +45,9 @@ export default class MultiSelectPrompt extends Prompt value !== this._value) diff --git a/packages/core/test/prompts/group-multi-select.test.ts b/packages/core/test/prompts/group-multi-select.test.ts new file mode 100644 index 00000000..73f896b4 --- /dev/null +++ b/packages/core/test/prompts/group-multi-select.test.ts @@ -0,0 +1,199 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as GroupMultiSelectPrompt } from '../../src/prompts/group-multiselect.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('GroupMultiSelectPrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [{ value: 'a' }, { value: 'b' }], + }, + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + describe('cursor with separators', () => { + // Flattened layout: [groupHeader(0), a(1), separator(2), b(3)] + test('cursor skips separator options', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [ + { value: 'a' }, + { type: 'separator' as const, label: '---' }, + { value: 'b' }, + ], + }, + }); + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(1); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(3); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(1); + }); + + // Flattened layout: [groupHeader(0), separator(1), a(2), b(3)] + test('initial cursor skips separator at fallback position', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [ + { type: 'separator' as const, label: '---' }, + { value: 'a' }, + { value: 'b' }, + ], + }, + }); + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + }); + + // Flattened layout: [groupHeader(0), a(1), separator(2)] + test('cursor skips separator at end when wrapping', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [ + { value: 'a' }, + { type: 'separator' as const, label: '---' }, + ], + }, + }); + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(1); + }); + + // Flattened: [g1Header(0), a(1), g2Header(2), separator(3), b(4)] + test('cursor skips separators across groups', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [{ value: 'a' }], + group2: [ + { type: 'separator' as const, label: '---' }, + { value: 'b' }, + ], + }, + }); + instance.prompt(); + + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(1); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(4); + }); + + // Flattened: [groupHeader(0), a(1), separator(2), b(3)] + test('toggleValue is a no-op on separator', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [ + { value: 'a' }, + { type: 'separator' as const, label: '---' }, + { value: 'b' }, + ], + }, + }); + instance.prompt(); + + expect(instance.value).toEqual([]); + + instance.cursor = 2; + input.emit('keypress', 'space', { name: 'space' }); + expect(instance.value).toEqual([]); + + instance.cursor = 3; + input.emit('keypress', 'space', { name: 'space' }); + expect(instance.value).toEqual(['b']); + + instance.cursor = 2; + input.emit('keypress', 'space', { name: 'space' }); + expect(instance.value).toEqual(['b']); + }); + + // Flattened: [groupHeader(0), separator(1), a(2), b(3)] + test('cursorAt skips separator', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: { + group1: [ + { type: 'separator' as const, label: '---' }, + { value: 'a' }, + { value: 'b' }, + ], + }, + cursorAt: 'a', + }); + instance.prompt(); + + expect(instance.cursor).to.equal(2); + }); + }); + + describe('selectableGroups = false with separators', () => { + // Flattened: [groupHeader(0), separator(1), a(2), b(3)] + test('cursor skips separator and group header', () => { + const instance = new GroupMultiSelectPrompt({ + input, + output, + render: () => 'foo', + selectableGroups: false, + options: { + group1: [ + { type: 'separator' as const, label: '---' }, + { value: 'a' }, + { value: 'b' }, + ], + }, + }); + instance.prompt(); + + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(3); + }); + }); +}); diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 1a14494c..60b76c4c 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; +import { GroupMultiSelectPrompt, isSeparator, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -10,14 +10,8 @@ import { symbol, } from './common.js'; import { limitOptions } from './limit-options.js'; -import type { Option, SeparatorOption } from './select.js'; +import type { Option } from './select.js'; -const isSeparatorOption = (opt: Option): opt is SeparatorOption => - 'type' in opt && opt.type === 'separator'; - -/** - * Options for the {@link groupMultiselect} prompt. - */ export interface GroupMultiSelectOptions extends CommonOptions { /** * The message or question shown to the user above the input. @@ -231,7 +225,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => active: boolean ) => { const options = this.options; - if (isSeparatorOption(option)) { + if (isSeparator(option)) { return opt(option, 'separator', options); } const selected = diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index 1eef403b..3af4f92e 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { getColumns, getRows } from '@clack/core'; +import { getColumns, getRows, isSeparator } from '@clack/core'; import { wrapAnsi } from 'fast-wrap-ansi'; import type { CommonOptions } from './common.js'; @@ -53,8 +53,8 @@ export const limitOptions = ({ const overflowFormat = styleText('dim', '...'); const outputMaxItems = Math.max(rows - rowPadding, 0); - // We clamp to minimum 5 because anything less doesn't make sense UX wise - const computedMaxItems = Math.max(Math.min(maxItems, outputMaxItems), 5); + const separatorCount = options.filter((opt) => isSeparator(opt)).length; + const computedMaxItems = Math.max(Math.min(maxItems + separatorCount, outputMaxItems), 5); let slidingWindowLocation = 0; if (cursor >= computedMaxItems - 3) { diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 1def83f1..d27c29aa 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { MultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; +import { MultiSelectPrompt, isSeparator, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -11,10 +11,7 @@ import { symbolBar, } from './common.js'; import { limitOptions } from './limit-options.js'; -import type { Option, SeparatorOption } from './select.js'; - -const isSeparatorOption = (opt: Option): opt is SeparatorOption => - 'type' in opt && opt.type === 'separator'; +import type { Option } from './select.js'; export interface MultiSelectOptions extends CommonOptions { message: string; @@ -111,7 +108,7 @@ export const multiselect = (opts: MultiSelectOptions) => { const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { - if (isSeparatorOption(option)) { + if (isSeparator(option)) { return opt(option, 'separator'); } if (option.disabled) { diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index dd9aaed3..19bb7b69 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { SelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; +import { SelectPrompt, isSeparator, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -96,9 +96,6 @@ const computeLabel = (label: string, format: (text: string) => string) => { .join('\n'); }; -const isSeparatorOption = (opt: Option): opt is SeparatorOption => - 'type' in opt && opt.type === 'separator'; - export const select = (opts: SelectOptions) => { const opt = ( option: Option, @@ -177,7 +174,7 @@ export const select = (opts: SelectOptions) => { columnPadding: prefix.length, rowPadding: titleLineCount + footerLineCount, style: (item, active) => - isSeparatorOption(item) + isSeparator(item) ? opt(item, 'separator') : opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), }).join(`\n${prefix}`)}\n${prefixEnd}\n`;