inquirerjs-checkbox-search 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,36 +10,9 @@ This prompt combines the functionality of `@inquirer/checkbox` and `@inquirer/se
10
10
 
11
11
  ## Installation
12
12
 
13
- <table>
14
- <tr>
15
- <th>npm</th>
16
- <th>yarn</th>
17
- <th>pnpm</th>
18
- </tr>
19
- <tr>
20
- <td>
21
-
22
- ```sh
23
- npm install inquirerjs-checkbox-search
24
- ```
25
-
26
- </td>
27
- <td>
28
-
29
- ```sh
30
- yarn add inquirerjs-checkbox-search
31
- ```
32
-
33
- </td>
34
- <td>
35
-
36
- ```sh
37
- pnpm add inquirerjs-checkbox-search
38
- ```
39
-
40
- </td>
41
- </tr>
42
- </table>
13
+ - **npm**: `npm install inquirerjs-checkbox-search`
14
+ - **yarn**: `yarn add inquirerjs-checkbox-search`
15
+ - **pnpm**: `pnpm add inquirerjs-checkbox-search`
43
16
 
44
17
  ## Usage
45
18
 
@@ -65,19 +38,19 @@ console.log('Selected:', selected);
65
38
 
66
39
  ### Options
67
40
 
68
- | Property | Type | Required | Description |
69
- | -------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------------------------ |
70
- | `message` | `string` | Yes | The question to ask |
71
- | `choices` | `Array<Choice \| string \| Separator>` | No\* | Static list of choices |
72
- | `source` | `(term?: string, opt: { signal: AbortSignal }) => Promise<Array<Choice \| string>>` | No\* | Async function for dynamic choices |
73
- | `pageSize` | `number` | No | Number of choices to display per page (default: 7) |
74
- | `loop` | `boolean` | No | Whether to loop around when navigating (default: true) |
75
- | `required` | `boolean` | No | Require at least one selection (default: false) |
76
- | `validate` | `(selection: Array<Choice>) => boolean \| string \| Promise<string \| boolean>` | No | Custom validation function |
77
- | `instructions` | `string \| boolean` | No | Custom instructions text or false to hide |
78
- | `theme` | `Theme` | No | Custom theme configuration |
79
- | `default` | `Array<Value>` | No | Initially selected values |
80
- | `filter` | `(items: Array<Choice>, term: string) => Array<Choice>` | No | Custom filter function |
41
+ | Property | Type | Required | Description |
42
+ | -------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
43
+ | `message` | `string` | Yes | The question to ask |
44
+ | `choices` | `Array<Choice \| string \| Separator>` | No\* | Static list of choices |
45
+ | `source` | `(term?: string, opt: { signal: AbortSignal }) => Promise<Array<Choice \| string>>` | No\* | Async function for dynamic choices |
46
+ | `pageSize` | `number \| PageSizeConfig` | No | Page size configuration. Can be a number (fixed size) or PageSizeConfig object for advanced control. If not specified, auto-sizes based on terminal height (fallback: 7) |
47
+ | `loop` | `boolean` | No | Whether to loop around when navigating (default: true) |
48
+ | `required` | `boolean` | No | Require at least one selection (default: false) |
49
+ | `validate` | `(selection: Array<Choice>) => boolean \| string \| Promise<string \| boolean>` | No | Custom validation function |
50
+ | `instructions` | `string \| boolean` | No | Custom instructions text or false to hide |
51
+ | `theme` | `Theme` | No | Custom theme configuration |
52
+ | `default` | `Array<Value>` | No | Initially selected values |
53
+ | `filter` | `(items: Array<Choice>, term: string) => Array<Choice>` | No | Custom filter function |
81
54
 
82
55
  \*Either `choices` or `source` must be provided.
83
56
 
@@ -94,6 +67,33 @@ type Choice<Value = any> = {
94
67
  };
95
68
  ```
96
69
 
70
+ ### PageSize Configuration
71
+
72
+ The `pageSize` property accepts either a number (for simple fixed sizing) or a `PageSizeConfig` object for advanced control:
73
+
74
+ ```typescript
75
+ type PageSizeConfig = {
76
+ base?: number; // Starting page size (if not specified, auto-calculated from terminal)
77
+ max?: number; // Maximum page size (absolute constraint)
78
+ min?: number; // Minimum page size (absolute constraint, defaults to 1)
79
+ buffer?: number; // Fixed buffer lines to subtract from page size
80
+ autoBufferDescriptions?: boolean; // Auto-reserve space for descriptions
81
+ autoBufferCountsLineWidth?: boolean; // Consider terminal width when counting description lines
82
+ minBuffer?: number; // Minimum buffer lines (applied after auto/manual buffer)
83
+ };
84
+ ```
85
+
86
+ **Buffer Calculation Process:**
87
+
88
+ 1. Start with base page size (from `base` or auto-calculated)
89
+ 2. Calculate buffer:
90
+ - If `autoBufferDescriptions` is true: Add lines needed for largest description
91
+ - Otherwise: Add `buffer` value (if specified)
92
+ - Ensure buffer is at least `minBuffer` (if specified)
93
+ 3. Subtract buffer from base page size
94
+ 4. Apply `min`/`max` constraints
95
+ 5. Ensure final result is at least 1
96
+
97
97
  ### Theme Options
98
98
 
99
99
  ```typescript
@@ -129,21 +129,7 @@ type CheckboxSearchTheme = {
129
129
 
130
130
  ## Advanced Features
131
131
 
132
- For detailed examples of advanced features, see the [`examples/`](./examples/) directory:
133
-
134
- - **[Basic Multi-Select](./examples/basic.js)** - Simple multi-select functionality
135
- - **[Search Filtering](./examples/search-filtering.js)** - Real-time search with larger lists
136
- - **[Async Source](./examples/async-source.js)** - Dynamic loading with mock API
137
- - **[Custom Theme](./examples/custom-theme.js)** - Custom icons and styling
138
- - **[Validation](./examples/validation.js)** - Input validation and pre-selection
139
- - **[Custom Filter](./examples/custom-filter.js)** - Fuzzy matching filter
140
-
141
- **To run examples:**
142
-
143
- 1. Build the package: `npm run build`
144
- 2. Run any example: `node examples/basic.js`
145
-
146
- See the [examples README](./examples/README.md) for detailed instructions.
132
+ For detailed examples of advanced features, see the [`examples/`](./examples/) directory.
147
133
 
148
134
  Each example includes detailed comments and demonstrates real-world usage patterns.
149
135
 
@@ -44,6 +44,49 @@ export type NormalizedChoice<Value> = {
44
44
  disabled: boolean | string;
45
45
  checked: boolean;
46
46
  };
47
+ /**
48
+ * Configuration options for the checkbox-search prompt
49
+ */
50
+ export type PageSizeConfig = {
51
+ base?: number;
52
+ max?: number;
53
+ min?: number;
54
+ autoBufferDescriptions?: boolean;
55
+ buffer?: number;
56
+ minBuffer?: number;
57
+ autoBufferCountsLineWidth?: boolean;
58
+ };
59
+ export type PageSize = number | PageSizeConfig;
60
+ /**
61
+ * Internal item type (choice or separator)
62
+ */
63
+ type Item<Value> = NormalizedChoice<Value> | Separator;
64
+ /**
65
+ * Validate PageSizeConfig object for correctness
66
+ * @param config - PageSizeConfig to validate
67
+ * @throws Error if validation fails
68
+ */
69
+ export declare function validatePageSizeConfig(config: PageSizeConfig): void;
70
+ /**
71
+ * Calculate the maximum number of lines needed for descriptions across all items
72
+ * @param items - Array of items to analyze
73
+ * @param countLineWidth - Whether to consider terminal width for line wrapping
74
+ * @returns Maximum lines needed by any description
75
+ */
76
+ export declare function calculateDescriptionLines<Value>(items: readonly Item<Value>[], countLineWidth: boolean): number;
77
+ /**
78
+ * Resolve PageSize configuration to a final numeric page size
79
+ * @param pageSize - PageSize configuration (number or PageSizeConfig)
80
+ * @param items - Current items to consider for auto-buffering
81
+ * @returns Final resolved page size
82
+ */
83
+ export declare function resolvePageSize<Value>(pageSize: PageSize, items: readonly Item<Value>[]): number;
84
+ /**
85
+ * Calculate dynamic page size based on terminal height
86
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
87
+ * @returns Calculated page size
88
+ */
89
+ export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
47
90
  /**
48
91
  * Main checkbox-search prompt implementation
49
92
  *
@@ -63,7 +106,7 @@ export type NormalizedChoice<Value> = {
63
106
  declare const _default: <Value>(config: {
64
107
  message: string;
65
108
  prefix?: string | undefined;
66
- pageSize?: number | undefined;
109
+ pageSize?: PageSize | undefined;
67
110
  instructions?: string | boolean | undefined;
68
111
  choices?: readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | undefined;
69
112
  source?: ((term: string | undefined, opt: {
@@ -4,6 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Separator = void 0;
7
+ exports.validatePageSizeConfig = validatePageSizeConfig;
8
+ exports.calculateDescriptionLines = calculateDescriptionLines;
9
+ exports.resolvePageSize = resolvePageSize;
10
+ exports.calculateDynamicPageSize = calculateDynamicPageSize;
7
11
  const core_1 = require("@inquirer/core");
8
12
  const yoctocolors_cjs_1 = __importDefault(require("yoctocolors-cjs"));
9
13
  const figures_1 = __importDefault(require("@inquirer/figures"));
@@ -24,7 +28,7 @@ const checkboxSearchTheme = {
24
28
  help: yoctocolors_cjs_1.default.dim,
25
29
  highlight: yoctocolors_cjs_1.default.cyan,
26
30
  searchTerm: yoctocolors_cjs_1.default.cyan,
27
- description: yoctocolors_cjs_1.default.dim,
31
+ description: yoctocolors_cjs_1.default.cyan,
28
32
  disabled: yoctocolors_cjs_1.default.dim,
29
33
  },
30
34
  helpMode: 'always',
@@ -104,6 +108,142 @@ function defaultFilter(items, term) {
104
108
  value.includes(searchTerm));
105
109
  });
106
110
  }
111
+ /**
112
+ * Validate PageSizeConfig object for correctness
113
+ * @param config - PageSizeConfig to validate
114
+ * @throws Error if validation fails
115
+ */
116
+ function validatePageSizeConfig(config) {
117
+ if (config.min !== undefined && config.min < 1) {
118
+ throw new Error('PageSize min cannot be less than 1');
119
+ }
120
+ if (config.base !== undefined && config.base < 1) {
121
+ throw new Error('PageSize base cannot be less than 1');
122
+ }
123
+ if (config.buffer !== undefined && config.buffer < 0) {
124
+ throw new Error('PageSize buffer cannot be negative');
125
+ }
126
+ if (config.minBuffer !== undefined && config.minBuffer < 0) {
127
+ throw new Error('PageSize minBuffer cannot be negative');
128
+ }
129
+ if (config.min !== undefined &&
130
+ config.max !== undefined &&
131
+ config.min > config.max) {
132
+ throw new Error(`PageSize min (${config.min}) cannot be greater than max (${config.max})`);
133
+ }
134
+ }
135
+ /**
136
+ * Calculate the maximum number of lines needed for descriptions across all items
137
+ * @param items - Array of items to analyze
138
+ * @param countLineWidth - Whether to consider terminal width for line wrapping
139
+ * @returns Maximum lines needed by any description
140
+ */
141
+ function calculateDescriptionLines(items, countLineWidth) {
142
+ let maxLines = 0;
143
+ for (const item of items) {
144
+ if (core_1.Separator.isSeparator(item) || !item.description) {
145
+ continue;
146
+ }
147
+ let lines;
148
+ if (countLineWidth) {
149
+ // Consider terminal width for wrapping
150
+ const terminalWidth = process.stdout.columns || 80;
151
+ const descriptionLines = item.description.split('\n');
152
+ lines = descriptionLines.reduce((total, line) => {
153
+ return total + (Math.ceil(line.length / terminalWidth) || 1);
154
+ }, 0);
155
+ }
156
+ else {
157
+ // Simple newline counting
158
+ lines = item.description.split('\n').length;
159
+ }
160
+ maxLines = Math.max(maxLines, lines);
161
+ }
162
+ return maxLines;
163
+ }
164
+ /**
165
+ * Resolve PageSize configuration to a final numeric page size
166
+ * @param pageSize - PageSize configuration (number or PageSizeConfig)
167
+ * @param items - Current items to consider for auto-buffering
168
+ * @returns Final resolved page size
169
+ */
170
+ function resolvePageSize(pageSize, items) {
171
+ // Handle simple number case (backward compatibility)
172
+ if (typeof pageSize === 'number') {
173
+ return pageSize;
174
+ }
175
+ // Validate the configuration
176
+ validatePageSizeConfig(pageSize);
177
+ // Step 1: Determine base page size
178
+ let basePageSize;
179
+ if (pageSize.base !== undefined) {
180
+ basePageSize = pageSize.base;
181
+ }
182
+ else {
183
+ // Auto-calculate using existing logic
184
+ basePageSize = calculateDynamicPageSize(7);
185
+ }
186
+ // Step 2: Calculate buffer reduction
187
+ let buffer = 0;
188
+ // 2a: Start at 0 ✓
189
+ // 2b: If autoBufferDescriptions, add max description lines
190
+ if (pageSize.autoBufferDescriptions) {
191
+ buffer += calculateDescriptionLines(items, pageSize.autoBufferCountsLineWidth || false);
192
+ }
193
+ else {
194
+ // 2c: Add buffer value (only if not auto-buffering)
195
+ buffer += pageSize.buffer || 0;
196
+ }
197
+ // 2d: Ensure at least minBuffer
198
+ if (pageSize.minBuffer !== undefined) {
199
+ buffer = Math.max(buffer, pageSize.minBuffer);
200
+ }
201
+ // Step 3: Subtract buffer from base
202
+ let finalPageSize = basePageSize - buffer;
203
+ // Step 4: Apply min/max constraints
204
+ if (pageSize.min !== undefined) {
205
+ finalPageSize = Math.max(finalPageSize, pageSize.min);
206
+ }
207
+ if (pageSize.max !== undefined) {
208
+ finalPageSize = Math.min(finalPageSize, pageSize.max);
209
+ }
210
+ // Ensure minimum of 1
211
+ return Math.max(1, finalPageSize);
212
+ }
213
+ /**
214
+ * Calculate dynamic page size based on terminal height
215
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
216
+ * @returns Calculated page size
217
+ */
218
+ function calculateDynamicPageSize(fallbackPageSize) {
219
+ let rawPageSize;
220
+ try {
221
+ // Get terminal height from process.stdout.rows
222
+ const terminalHeight = process.stdout.rows;
223
+ if (!terminalHeight || terminalHeight < 1) {
224
+ // Fallback to static page size if terminal height is not available
225
+ rawPageSize = fallbackPageSize;
226
+ }
227
+ else {
228
+ // Reserve space for UI elements:
229
+ // - 1 line for the prompt message
230
+ // - 1 line for help instructions
231
+ // - 1 line for search input (if present)
232
+ // - 1 line for error messages (if present)
233
+ // - 1 line for description (if present)
234
+ // - 1 line for buffer/spacing
235
+ const reservedLines = 6;
236
+ // Calculate available lines for choices
237
+ rawPageSize = terminalHeight - reservedLines;
238
+ }
239
+ }
240
+ catch {
241
+ // If there's any error accessing terminal dimensions, fallback gracefully
242
+ rawPageSize = fallbackPageSize;
243
+ }
244
+ // Ensure minimum page size for usability and cap maximum to prevent overwhelming display
245
+ return Math.max(2, Math.min(rawPageSize, 50));
246
+ }
107
247
  /**
108
248
  * Main checkbox-search prompt implementation
109
249
  *
@@ -124,7 +264,7 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
124
264
  // Stable reference for empty array to prevent unnecessary recalculations
125
265
  const emptyArray = (0, core_1.useMemo)(() => [], []);
126
266
  // Configuration with defaults
127
- const { pageSize = 7, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
267
+ const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
128
268
  const theme = (0, core_1.makeTheme)(checkboxSearchTheme, config.theme);
129
269
  // State management hooks
130
270
  const [status, setStatus] = (0, core_1.useState)('idle');
@@ -147,6 +287,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
147
287
  }
148
288
  return [];
149
289
  });
290
+ // Calculate effective page size (memoized with terminal size tracking)
291
+ // Use new resolvePageSize function to handle both number and PageSizeConfig
292
+ const terminalHeight = process.stdout.rows; // Track terminal size for memoization
293
+ const pageSize = (0, core_1.useMemo)(() => {
294
+ if (configPageSize !== undefined) {
295
+ return resolvePageSize(configPageSize, allItems);
296
+ }
297
+ else {
298
+ // Default behavior - auto-calculate
299
+ return calculateDynamicPageSize(7);
300
+ }
301
+ }, [configPageSize, terminalHeight, allItems]);
302
+ // Store the active item value instead of active index
303
+ const [activeItemValue, setActiveItemValue] = (0, core_1.useState)(null);
150
304
  // Compute filtered items based on search term and filtering logic
151
305
  const filteredItems = (0, core_1.useMemo)(() => {
152
306
  // Async source mode - use allItems directly (source handles filtering)
@@ -175,8 +329,48 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
175
329
  }
176
330
  return result;
177
331
  }, [allItems, searchTerm, config.source, config.filter]);
178
- const [active, setActive] = (0, core_1.useState)(0);
332
+ // Compute active index from activeItemValue
333
+ const active = (0, core_1.useMemo)(() => {
334
+ if (activeItemValue === null) {
335
+ // No active item set, default to first selectable
336
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
337
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
338
+ }
339
+ // Find the item with the active value
340
+ const activeIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
341
+ item.value === activeItemValue);
342
+ if (activeIndex !== -1) {
343
+ return activeIndex;
344
+ }
345
+ // Active item not found in filtered list, default to first selectable
346
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
347
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
348
+ }, [filteredItems, activeItemValue]);
349
+ // Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
350
+ (0, core_1.useEffect)(() => {
351
+ const activeItem = filteredItems[active];
352
+ if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
353
+ const currentActiveValue = activeItem
354
+ .value;
355
+ if (activeItemValue !== currentActiveValue) {
356
+ setActiveItemValue(currentActiveValue);
357
+ }
358
+ }
359
+ }, [active, filteredItems, activeItemValue]);
179
360
  const [errorMsg, setError] = (0, core_1.useState)();
361
+ // Hide cursor on mount, show on unmount (like other inquirer prompts)
362
+ (0, core_1.useEffect)(() => {
363
+ // Hide cursor when prompt starts (only in TTY environments)
364
+ if (process.stdout.isTTY) {
365
+ process.stdout.write(ansi_escapes_1.default.cursorHide);
366
+ }
367
+ // Show cursor when prompt ends (cleanup function)
368
+ return () => {
369
+ if (process.stdout.isTTY) {
370
+ process.stdout.write(ansi_escapes_1.default.cursorShow);
371
+ }
372
+ };
373
+ }, []);
180
374
  // Handle async source - load data based on search term
181
375
  (0, core_1.useEffect)(() => {
182
376
  if (!config.source) {
@@ -213,31 +407,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
213
407
  (0, core_1.useEffect)(() => {
214
408
  allItemsRef.current = allItems;
215
409
  }, [allItems]);
216
- // Reset active index when filtered items change, but preserve cursor position during selection toggles
217
- (0, core_1.useEffect)(() => {
218
- // Don't reset cursor position if we're just toggling selection on the same items
219
- // Only reset when the actual set of filtered items changes (not their selection state)
220
- const currentFilteredValues = filteredItems
221
- .filter((item) => !core_1.Separator.isSeparator(item))
222
- .map((item) => item.value);
223
- const activeItem = filteredItems[active];
224
- const activeValue = activeItem && !core_1.Separator.isSeparator(activeItem)
225
- ? activeItem.value
226
- : null;
227
- // If the currently active item is still in the filtered list, preserve its position
228
- if (activeValue && currentFilteredValues.includes(activeValue)) {
229
- const newActiveIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
230
- item.value === activeValue);
231
- if (newActiveIndex !== -1) {
232
- setActive(newActiveIndex);
233
- return;
234
- }
235
- }
236
- // Otherwise reset to 0 (when filtering actually changes the set of items)
237
- setActive(0);
238
- }, [filteredItems, active]);
239
410
  // Keyboard event handling
240
411
  (0, core_1.useKeypress)((key, rl) => {
412
+ // Helper function to update search term in both readline and React state
413
+ const updateSearchTerm = (newTerm) => {
414
+ rl.clearLine(0);
415
+ rl.write(newTerm);
416
+ setSearchTerm(newTerm);
417
+ };
241
418
  // Allow search input even during loading, but block other actions
242
419
  const isNavigationOrAction = key.name === 'up' ||
243
420
  key.name === 'down' ||
@@ -251,13 +428,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
251
428
  setError(undefined);
252
429
  // Handle Escape key - clear search term quickly
253
430
  if (key.name === 'escape') {
254
- rl.line = ''; // Clear readline input first (avoid re-render)
255
- setSearchTerm(''); // Then update state
431
+ updateSearchTerm(''); // Clear both readline and React state
256
432
  return;
257
433
  }
258
- // Handle navigation
259
- if ((0, core_1.isUpKey)(key) || (0, core_1.isDownKey)(key)) {
260
- const direction = (0, core_1.isUpKey)(key) ? -1 : 1;
434
+ // Handle navigation - ONLY actual arrow keys (not vim j/k keys)
435
+ // This follows the official inquirer.js search approach
436
+ if (key.name === 'up' || key.name === 'down') {
437
+ rl.clearLine(0); // Clean readline state before navigation
438
+ const direction = key.name === 'up' ? -1 : 1;
261
439
  const selectableIndexes = filteredItems
262
440
  .map((item, index) => ({ item, index }))
263
441
  .filter(({ item }) => isSelectable(item))
@@ -275,14 +453,22 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
275
453
  else {
276
454
  nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
277
455
  }
278
- setActive(selectableIndexes[nextSelectableIndex] || 0);
456
+ // Translate from position inside `selectableIndexes` to the real index
457
+ const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
458
+ const nextSelectableItem = filteredItems[nextFilteredIndex];
459
+ if (nextSelectableItem && isSelectable(nextSelectableItem)) {
460
+ setActiveItemValue(nextSelectableItem.value);
461
+ }
279
462
  return;
280
463
  }
281
- // Handle selection toggle with tab key ONLY - prevent tab from affecting search text
464
+ // Handle selection toggle with tab key
282
465
  if (key.name === 'tab') {
466
+ const preservedSearchTerm = searchTerm;
283
467
  const activeItem = filteredItems[active];
284
468
  if (activeItem && isSelectable(activeItem)) {
285
469
  const activeValue = activeItem.value;
470
+ // Set this as the active item value so cursor position is preserved
471
+ setActiveItemValue(activeValue);
286
472
  setAllItems(allItems.map((item) => {
287
473
  // Compare by value only for robust matching
288
474
  if (!core_1.Separator.isSeparator(item) && item.value === activeValue) {
@@ -292,7 +478,10 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
292
478
  return item;
293
479
  }));
294
480
  }
295
- return; // Important: return here to prevent tab from being added to search term
481
+ updateSearchTerm(preservedSearchTerm);
482
+ // return to prevent tab from affecting search text:
483
+ // Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
484
+ return;
296
485
  }
297
486
  // Handle submission
298
487
  if ((0, core_1.isEnterKey)(key)) {
@@ -336,9 +525,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
336
525
  // Handle all other input as search term updates EXCEPT tab
337
526
  // Only update search term for actual typing, not navigation keys
338
527
  if (!isNavigationOrAction) {
528
+ // For general input, only update React state since rl.line is already current
339
529
  setSearchTerm(rl.line);
340
530
  }
341
531
  });
532
+ // Calculate the active item's description using useMemo for better React patterns
533
+ const activeDescription = (0, core_1.useMemo)(() => {
534
+ const activeItem = filteredItems[active];
535
+ if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
536
+ return activeItem.description;
537
+ }
538
+ return undefined;
539
+ }, [active, filteredItems]);
342
540
  // Create renderItem function that's reactive to current state
343
541
  const renderItem = (0, core_1.useMemo)(() => {
344
542
  return ({ item, isActive }) => {
@@ -363,32 +561,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
363
561
  let text = item.name;
364
562
  if (isActive) {
365
563
  text = theme.style.highlight(text);
564
+ // NOTE: Description is now calculated via useMemo, not side-effect mutation
366
565
  }
367
566
  else if (item.disabled) {
368
567
  text = theme.style.disabled(text);
369
568
  }
370
569
  line.push(text);
371
- // Show disabled reason if item is disabled
570
+ // Show disabled reason if item is disabled (but no descriptions inline anymore)
372
571
  if (item.disabled) {
373
572
  const disabledReason = typeof item.disabled === 'string'
374
573
  ? item.disabled
375
574
  : 'disabled';
376
575
  line.push(theme.style.disabled(`(${disabledReason})`));
377
576
  }
378
- else if (item.description) {
379
- const description = item.description;
380
- // If using custom description styling, give full control to user (no parentheses)
381
- // If using default styling, add parentheses for backward compatibility
382
- const isUsingCustomDescriptionStyle = config.theme?.style?.description !== undefined;
383
- if (description) {
384
- if (isUsingCustomDescriptionStyle) {
385
- line.push(theme.style.description(description));
386
- }
387
- else {
388
- line.push(`(${theme.style.description(description)})`);
389
- }
390
- }
391
- }
577
+ // NOTE: Removed the inline description display - descriptions now appear at bottom
392
578
  return line.join(' ');
393
579
  };
394
580
  }, [allItems, theme, config.theme]);
@@ -403,12 +589,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
403
589
  // Render the prompt
404
590
  const message = theme.style.message(config.message, status);
405
591
  let helpTip = '';
406
- if (theme.helpMode === 'always') {
407
- const tips = ['Tab to select', 'Enter to submit'];
408
- helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
592
+ if (theme.helpMode === 'always' && config.instructions !== false) {
593
+ if (typeof config.instructions === 'string') {
594
+ helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
595
+ }
596
+ else {
597
+ const tips = ['Tab to select', 'Enter to submit'];
598
+ helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
599
+ }
409
600
  }
410
601
  let searchLine = '';
411
- if (config.source || searchTerm || status === 'loading') {
602
+ // Always show search line when search functionality is available (static choices or async source)
603
+ if (config.source || config.choices || searchTerm || status === 'loading') {
412
604
  const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
413
605
  const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
414
606
  searchLine = `\n${searchPrefix} ${styledTerm}`;
@@ -430,7 +622,12 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
430
622
  else {
431
623
  content = `\n${page}`;
432
624
  }
433
- return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${ansi_escapes_1.default.cursorHide}`;
625
+ // Add description of active item at the bottom (like original inquirer.js)
626
+ let descriptionLine = '';
627
+ if (activeDescription) {
628
+ descriptionLine = `\n${theme.style.description(activeDescription)}`;
629
+ }
630
+ return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
434
631
  });
435
632
  // Re-export Separator for convenience
436
633
  var core_2 = require("@inquirer/core");
@@ -44,6 +44,49 @@ export type NormalizedChoice<Value> = {
44
44
  disabled: boolean | string;
45
45
  checked: boolean;
46
46
  };
47
+ /**
48
+ * Configuration options for the checkbox-search prompt
49
+ */
50
+ export type PageSizeConfig = {
51
+ base?: number;
52
+ max?: number;
53
+ min?: number;
54
+ autoBufferDescriptions?: boolean;
55
+ buffer?: number;
56
+ minBuffer?: number;
57
+ autoBufferCountsLineWidth?: boolean;
58
+ };
59
+ export type PageSize = number | PageSizeConfig;
60
+ /**
61
+ * Internal item type (choice or separator)
62
+ */
63
+ type Item<Value> = NormalizedChoice<Value> | Separator;
64
+ /**
65
+ * Validate PageSizeConfig object for correctness
66
+ * @param config - PageSizeConfig to validate
67
+ * @throws Error if validation fails
68
+ */
69
+ export declare function validatePageSizeConfig(config: PageSizeConfig): void;
70
+ /**
71
+ * Calculate the maximum number of lines needed for descriptions across all items
72
+ * @param items - Array of items to analyze
73
+ * @param countLineWidth - Whether to consider terminal width for line wrapping
74
+ * @returns Maximum lines needed by any description
75
+ */
76
+ export declare function calculateDescriptionLines<Value>(items: readonly Item<Value>[], countLineWidth: boolean): number;
77
+ /**
78
+ * Resolve PageSize configuration to a final numeric page size
79
+ * @param pageSize - PageSize configuration (number or PageSizeConfig)
80
+ * @param items - Current items to consider for auto-buffering
81
+ * @returns Final resolved page size
82
+ */
83
+ export declare function resolvePageSize<Value>(pageSize: PageSize, items: readonly Item<Value>[]): number;
84
+ /**
85
+ * Calculate dynamic page size based on terminal height
86
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
87
+ * @returns Calculated page size
88
+ */
89
+ export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
47
90
  /**
48
91
  * Main checkbox-search prompt implementation
49
92
  *
@@ -63,7 +106,7 @@ export type NormalizedChoice<Value> = {
63
106
  declare const _default: <Value>(config: {
64
107
  message: string;
65
108
  prefix?: string | undefined;
66
- pageSize?: number | undefined;
109
+ pageSize?: PageSize | undefined;
67
110
  instructions?: string | boolean | undefined;
68
111
  choices?: readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | undefined;
69
112
  source?: ((term: string | undefined, opt: {
package/dist/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createPrompt, useState, useKeypress, usePagination, useEffect, useRef, useMemo, usePrefix, makeTheme, isUpKey, isDownKey, isEnterKey, Separator, } from '@inquirer/core';
1
+ import { createPrompt, useState, useKeypress, usePagination, useEffect, useRef, useMemo, usePrefix, makeTheme, isEnterKey, Separator, } from '@inquirer/core';
2
2
  import colors from 'yoctocolors-cjs';
3
3
  import figures from '@inquirer/figures';
4
4
  import ansiEscapes from 'ansi-escapes';
@@ -18,7 +18,7 @@ const checkboxSearchTheme = {
18
18
  help: colors.dim,
19
19
  highlight: colors.cyan,
20
20
  searchTerm: colors.cyan,
21
- description: colors.dim,
21
+ description: colors.cyan,
22
22
  disabled: colors.dim,
23
23
  },
24
24
  helpMode: 'always',
@@ -98,6 +98,142 @@ function defaultFilter(items, term) {
98
98
  value.includes(searchTerm));
99
99
  });
100
100
  }
101
+ /**
102
+ * Validate PageSizeConfig object for correctness
103
+ * @param config - PageSizeConfig to validate
104
+ * @throws Error if validation fails
105
+ */
106
+ export function validatePageSizeConfig(config) {
107
+ if (config.min !== undefined && config.min < 1) {
108
+ throw new Error('PageSize min cannot be less than 1');
109
+ }
110
+ if (config.base !== undefined && config.base < 1) {
111
+ throw new Error('PageSize base cannot be less than 1');
112
+ }
113
+ if (config.buffer !== undefined && config.buffer < 0) {
114
+ throw new Error('PageSize buffer cannot be negative');
115
+ }
116
+ if (config.minBuffer !== undefined && config.minBuffer < 0) {
117
+ throw new Error('PageSize minBuffer cannot be negative');
118
+ }
119
+ if (config.min !== undefined &&
120
+ config.max !== undefined &&
121
+ config.min > config.max) {
122
+ throw new Error(`PageSize min (${config.min}) cannot be greater than max (${config.max})`);
123
+ }
124
+ }
125
+ /**
126
+ * Calculate the maximum number of lines needed for descriptions across all items
127
+ * @param items - Array of items to analyze
128
+ * @param countLineWidth - Whether to consider terminal width for line wrapping
129
+ * @returns Maximum lines needed by any description
130
+ */
131
+ export function calculateDescriptionLines(items, countLineWidth) {
132
+ let maxLines = 0;
133
+ for (const item of items) {
134
+ if (Separator.isSeparator(item) || !item.description) {
135
+ continue;
136
+ }
137
+ let lines;
138
+ if (countLineWidth) {
139
+ // Consider terminal width for wrapping
140
+ const terminalWidth = process.stdout.columns || 80;
141
+ const descriptionLines = item.description.split('\n');
142
+ lines = descriptionLines.reduce((total, line) => {
143
+ return total + (Math.ceil(line.length / terminalWidth) || 1);
144
+ }, 0);
145
+ }
146
+ else {
147
+ // Simple newline counting
148
+ lines = item.description.split('\n').length;
149
+ }
150
+ maxLines = Math.max(maxLines, lines);
151
+ }
152
+ return maxLines;
153
+ }
154
+ /**
155
+ * Resolve PageSize configuration to a final numeric page size
156
+ * @param pageSize - PageSize configuration (number or PageSizeConfig)
157
+ * @param items - Current items to consider for auto-buffering
158
+ * @returns Final resolved page size
159
+ */
160
+ export function resolvePageSize(pageSize, items) {
161
+ // Handle simple number case (backward compatibility)
162
+ if (typeof pageSize === 'number') {
163
+ return pageSize;
164
+ }
165
+ // Validate the configuration
166
+ validatePageSizeConfig(pageSize);
167
+ // Step 1: Determine base page size
168
+ let basePageSize;
169
+ if (pageSize.base !== undefined) {
170
+ basePageSize = pageSize.base;
171
+ }
172
+ else {
173
+ // Auto-calculate using existing logic
174
+ basePageSize = calculateDynamicPageSize(7);
175
+ }
176
+ // Step 2: Calculate buffer reduction
177
+ let buffer = 0;
178
+ // 2a: Start at 0 ✓
179
+ // 2b: If autoBufferDescriptions, add max description lines
180
+ if (pageSize.autoBufferDescriptions) {
181
+ buffer += calculateDescriptionLines(items, pageSize.autoBufferCountsLineWidth || false);
182
+ }
183
+ else {
184
+ // 2c: Add buffer value (only if not auto-buffering)
185
+ buffer += pageSize.buffer || 0;
186
+ }
187
+ // 2d: Ensure at least minBuffer
188
+ if (pageSize.minBuffer !== undefined) {
189
+ buffer = Math.max(buffer, pageSize.minBuffer);
190
+ }
191
+ // Step 3: Subtract buffer from base
192
+ let finalPageSize = basePageSize - buffer;
193
+ // Step 4: Apply min/max constraints
194
+ if (pageSize.min !== undefined) {
195
+ finalPageSize = Math.max(finalPageSize, pageSize.min);
196
+ }
197
+ if (pageSize.max !== undefined) {
198
+ finalPageSize = Math.min(finalPageSize, pageSize.max);
199
+ }
200
+ // Ensure minimum of 1
201
+ return Math.max(1, finalPageSize);
202
+ }
203
+ /**
204
+ * Calculate dynamic page size based on terminal height
205
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
206
+ * @returns Calculated page size
207
+ */
208
+ export function calculateDynamicPageSize(fallbackPageSize) {
209
+ let rawPageSize;
210
+ try {
211
+ // Get terminal height from process.stdout.rows
212
+ const terminalHeight = process.stdout.rows;
213
+ if (!terminalHeight || terminalHeight < 1) {
214
+ // Fallback to static page size if terminal height is not available
215
+ rawPageSize = fallbackPageSize;
216
+ }
217
+ else {
218
+ // Reserve space for UI elements:
219
+ // - 1 line for the prompt message
220
+ // - 1 line for help instructions
221
+ // - 1 line for search input (if present)
222
+ // - 1 line for error messages (if present)
223
+ // - 1 line for description (if present)
224
+ // - 1 line for buffer/spacing
225
+ const reservedLines = 6;
226
+ // Calculate available lines for choices
227
+ rawPageSize = terminalHeight - reservedLines;
228
+ }
229
+ }
230
+ catch {
231
+ // If there's any error accessing terminal dimensions, fallback gracefully
232
+ rawPageSize = fallbackPageSize;
233
+ }
234
+ // Ensure minimum page size for usability and cap maximum to prevent overwhelming display
235
+ return Math.max(2, Math.min(rawPageSize, 50));
236
+ }
101
237
  /**
102
238
  * Main checkbox-search prompt implementation
103
239
  *
@@ -118,7 +254,7 @@ export default createPrompt((config, done) => {
118
254
  // Stable reference for empty array to prevent unnecessary recalculations
119
255
  const emptyArray = useMemo(() => [], []);
120
256
  // Configuration with defaults
121
- const { pageSize = 7, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
257
+ const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
122
258
  const theme = makeTheme(checkboxSearchTheme, config.theme);
123
259
  // State management hooks
124
260
  const [status, setStatus] = useState('idle');
@@ -141,6 +277,20 @@ export default createPrompt((config, done) => {
141
277
  }
142
278
  return [];
143
279
  });
280
+ // Calculate effective page size (memoized with terminal size tracking)
281
+ // Use new resolvePageSize function to handle both number and PageSizeConfig
282
+ const terminalHeight = process.stdout.rows; // Track terminal size for memoization
283
+ const pageSize = useMemo(() => {
284
+ if (configPageSize !== undefined) {
285
+ return resolvePageSize(configPageSize, allItems);
286
+ }
287
+ else {
288
+ // Default behavior - auto-calculate
289
+ return calculateDynamicPageSize(7);
290
+ }
291
+ }, [configPageSize, terminalHeight, allItems]);
292
+ // Store the active item value instead of active index
293
+ const [activeItemValue, setActiveItemValue] = useState(null);
144
294
  // Compute filtered items based on search term and filtering logic
145
295
  const filteredItems = useMemo(() => {
146
296
  // Async source mode - use allItems directly (source handles filtering)
@@ -169,8 +319,48 @@ export default createPrompt((config, done) => {
169
319
  }
170
320
  return result;
171
321
  }, [allItems, searchTerm, config.source, config.filter]);
172
- const [active, setActive] = useState(0);
322
+ // Compute active index from activeItemValue
323
+ const active = useMemo(() => {
324
+ if (activeItemValue === null) {
325
+ // No active item set, default to first selectable
326
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
327
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
328
+ }
329
+ // Find the item with the active value
330
+ const activeIndex = filteredItems.findIndex((item) => !Separator.isSeparator(item) &&
331
+ item.value === activeItemValue);
332
+ if (activeIndex !== -1) {
333
+ return activeIndex;
334
+ }
335
+ // Active item not found in filtered list, default to first selectable
336
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
337
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
338
+ }, [filteredItems, activeItemValue]);
339
+ // Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
340
+ useEffect(() => {
341
+ const activeItem = filteredItems[active];
342
+ if (activeItem && !Separator.isSeparator(activeItem)) {
343
+ const currentActiveValue = activeItem
344
+ .value;
345
+ if (activeItemValue !== currentActiveValue) {
346
+ setActiveItemValue(currentActiveValue);
347
+ }
348
+ }
349
+ }, [active, filteredItems, activeItemValue]);
173
350
  const [errorMsg, setError] = useState();
351
+ // Hide cursor on mount, show on unmount (like other inquirer prompts)
352
+ useEffect(() => {
353
+ // Hide cursor when prompt starts (only in TTY environments)
354
+ if (process.stdout.isTTY) {
355
+ process.stdout.write(ansiEscapes.cursorHide);
356
+ }
357
+ // Show cursor when prompt ends (cleanup function)
358
+ return () => {
359
+ if (process.stdout.isTTY) {
360
+ process.stdout.write(ansiEscapes.cursorShow);
361
+ }
362
+ };
363
+ }, []);
174
364
  // Handle async source - load data based on search term
175
365
  useEffect(() => {
176
366
  if (!config.source) {
@@ -207,31 +397,14 @@ export default createPrompt((config, done) => {
207
397
  useEffect(() => {
208
398
  allItemsRef.current = allItems;
209
399
  }, [allItems]);
210
- // Reset active index when filtered items change, but preserve cursor position during selection toggles
211
- useEffect(() => {
212
- // Don't reset cursor position if we're just toggling selection on the same items
213
- // Only reset when the actual set of filtered items changes (not their selection state)
214
- const currentFilteredValues = filteredItems
215
- .filter((item) => !Separator.isSeparator(item))
216
- .map((item) => item.value);
217
- const activeItem = filteredItems[active];
218
- const activeValue = activeItem && !Separator.isSeparator(activeItem)
219
- ? activeItem.value
220
- : null;
221
- // If the currently active item is still in the filtered list, preserve its position
222
- if (activeValue && currentFilteredValues.includes(activeValue)) {
223
- const newActiveIndex = filteredItems.findIndex((item) => !Separator.isSeparator(item) &&
224
- item.value === activeValue);
225
- if (newActiveIndex !== -1) {
226
- setActive(newActiveIndex);
227
- return;
228
- }
229
- }
230
- // Otherwise reset to 0 (when filtering actually changes the set of items)
231
- setActive(0);
232
- }, [filteredItems, active]);
233
400
  // Keyboard event handling
234
401
  useKeypress((key, rl) => {
402
+ // Helper function to update search term in both readline and React state
403
+ const updateSearchTerm = (newTerm) => {
404
+ rl.clearLine(0);
405
+ rl.write(newTerm);
406
+ setSearchTerm(newTerm);
407
+ };
235
408
  // Allow search input even during loading, but block other actions
236
409
  const isNavigationOrAction = key.name === 'up' ||
237
410
  key.name === 'down' ||
@@ -245,13 +418,14 @@ export default createPrompt((config, done) => {
245
418
  setError(undefined);
246
419
  // Handle Escape key - clear search term quickly
247
420
  if (key.name === 'escape') {
248
- rl.line = ''; // Clear readline input first (avoid re-render)
249
- setSearchTerm(''); // Then update state
421
+ updateSearchTerm(''); // Clear both readline and React state
250
422
  return;
251
423
  }
252
- // Handle navigation
253
- if (isUpKey(key) || isDownKey(key)) {
254
- const direction = isUpKey(key) ? -1 : 1;
424
+ // Handle navigation - ONLY actual arrow keys (not vim j/k keys)
425
+ // This follows the official inquirer.js search approach
426
+ if (key.name === 'up' || key.name === 'down') {
427
+ rl.clearLine(0); // Clean readline state before navigation
428
+ const direction = key.name === 'up' ? -1 : 1;
255
429
  const selectableIndexes = filteredItems
256
430
  .map((item, index) => ({ item, index }))
257
431
  .filter(({ item }) => isSelectable(item))
@@ -269,14 +443,22 @@ export default createPrompt((config, done) => {
269
443
  else {
270
444
  nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
271
445
  }
272
- setActive(selectableIndexes[nextSelectableIndex] || 0);
446
+ // Translate from position inside `selectableIndexes` to the real index
447
+ const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
448
+ const nextSelectableItem = filteredItems[nextFilteredIndex];
449
+ if (nextSelectableItem && isSelectable(nextSelectableItem)) {
450
+ setActiveItemValue(nextSelectableItem.value);
451
+ }
273
452
  return;
274
453
  }
275
- // Handle selection toggle with tab key ONLY - prevent tab from affecting search text
454
+ // Handle selection toggle with tab key
276
455
  if (key.name === 'tab') {
456
+ const preservedSearchTerm = searchTerm;
277
457
  const activeItem = filteredItems[active];
278
458
  if (activeItem && isSelectable(activeItem)) {
279
459
  const activeValue = activeItem.value;
460
+ // Set this as the active item value so cursor position is preserved
461
+ setActiveItemValue(activeValue);
280
462
  setAllItems(allItems.map((item) => {
281
463
  // Compare by value only for robust matching
282
464
  if (!Separator.isSeparator(item) && item.value === activeValue) {
@@ -286,7 +468,10 @@ export default createPrompt((config, done) => {
286
468
  return item;
287
469
  }));
288
470
  }
289
- return; // Important: return here to prevent tab from being added to search term
471
+ updateSearchTerm(preservedSearchTerm);
472
+ // return to prevent tab from affecting search text:
473
+ // Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
474
+ return;
290
475
  }
291
476
  // Handle submission
292
477
  if (isEnterKey(key)) {
@@ -330,9 +515,18 @@ export default createPrompt((config, done) => {
330
515
  // Handle all other input as search term updates EXCEPT tab
331
516
  // Only update search term for actual typing, not navigation keys
332
517
  if (!isNavigationOrAction) {
518
+ // For general input, only update React state since rl.line is already current
333
519
  setSearchTerm(rl.line);
334
520
  }
335
521
  });
522
+ // Calculate the active item's description using useMemo for better React patterns
523
+ const activeDescription = useMemo(() => {
524
+ const activeItem = filteredItems[active];
525
+ if (activeItem && !Separator.isSeparator(activeItem)) {
526
+ return activeItem.description;
527
+ }
528
+ return undefined;
529
+ }, [active, filteredItems]);
336
530
  // Create renderItem function that's reactive to current state
337
531
  const renderItem = useMemo(() => {
338
532
  return ({ item, isActive }) => {
@@ -357,32 +551,20 @@ export default createPrompt((config, done) => {
357
551
  let text = item.name;
358
552
  if (isActive) {
359
553
  text = theme.style.highlight(text);
554
+ // NOTE: Description is now calculated via useMemo, not side-effect mutation
360
555
  }
361
556
  else if (item.disabled) {
362
557
  text = theme.style.disabled(text);
363
558
  }
364
559
  line.push(text);
365
- // Show disabled reason if item is disabled
560
+ // Show disabled reason if item is disabled (but no descriptions inline anymore)
366
561
  if (item.disabled) {
367
562
  const disabledReason = typeof item.disabled === 'string'
368
563
  ? item.disabled
369
564
  : 'disabled';
370
565
  line.push(theme.style.disabled(`(${disabledReason})`));
371
566
  }
372
- else if (item.description) {
373
- const description = item.description;
374
- // If using custom description styling, give full control to user (no parentheses)
375
- // If using default styling, add parentheses for backward compatibility
376
- const isUsingCustomDescriptionStyle = config.theme?.style?.description !== undefined;
377
- if (description) {
378
- if (isUsingCustomDescriptionStyle) {
379
- line.push(theme.style.description(description));
380
- }
381
- else {
382
- line.push(`(${theme.style.description(description)})`);
383
- }
384
- }
385
- }
567
+ // NOTE: Removed the inline description display - descriptions now appear at bottom
386
568
  return line.join(' ');
387
569
  };
388
570
  }, [allItems, theme, config.theme]);
@@ -397,12 +579,18 @@ export default createPrompt((config, done) => {
397
579
  // Render the prompt
398
580
  const message = theme.style.message(config.message, status);
399
581
  let helpTip = '';
400
- if (theme.helpMode === 'always') {
401
- const tips = ['Tab to select', 'Enter to submit'];
402
- helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
582
+ if (theme.helpMode === 'always' && config.instructions !== false) {
583
+ if (typeof config.instructions === 'string') {
584
+ helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
585
+ }
586
+ else {
587
+ const tips = ['Tab to select', 'Enter to submit'];
588
+ helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
589
+ }
403
590
  }
404
591
  let searchLine = '';
405
- if (config.source || searchTerm || status === 'loading') {
592
+ // Always show search line when search functionality is available (static choices or async source)
593
+ if (config.source || config.choices || searchTerm || status === 'loading') {
406
594
  const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
407
595
  const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
408
596
  searchLine = `\n${searchPrefix} ${styledTerm}`;
@@ -424,7 +612,12 @@ export default createPrompt((config, done) => {
424
612
  else {
425
613
  content = `\n${page}`;
426
614
  }
427
- return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${ansiEscapes.cursorHide}`;
615
+ // Add description of active item at the bottom (like original inquirer.js)
616
+ let descriptionLine = '';
617
+ if (activeDescription) {
618
+ descriptionLine = `\n${theme.style.description(activeDescription)}`;
619
+ }
620
+ return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
428
621
  });
429
622
  // Re-export Separator for convenience
430
623
  export { Separator } from '@inquirer/core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inquirerjs-checkbox-search",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "A multi-select prompt with text filtering for inquirer.js",
5
5
  "keywords": [
6
6
  "answer",
@@ -64,14 +64,16 @@
64
64
  ],
65
65
  "scripts": {
66
66
  "build": "tshy",
67
- "clean": "rimraf dist .tshy",
67
+ "clean": "rimraf dist .tshy .tshy-build package-inspect coverage src/node_modules",
68
68
  "dev": "tshy --watch",
69
69
  "lint": "eslint . --ext .ts --fix",
70
70
  "lint:check": "eslint . --ext .ts",
71
71
  "format": "prettier --write .",
72
72
  "format:check": "prettier --check .",
73
- "test": "npm run format && npm run lint && npm run typecheck && npm run test:unit",
74
- "test:ci": "npm run format:check && npm run lint:check && npm run typecheck && npm run test:unit",
73
+ "quality": "npm run format && npm run lint && npm run typecheck",
74
+ "quality:check": "npm run format:check && npm run lint:check && npm run typecheck",
75
+ "test": "npm run quality && npm run test:unit",
76
+ "test:ci": "npm run quality:check && npm run test:coverage",
75
77
  "test:unit": "vitest run",
76
78
  "test:coverage": "vitest run --coverage",
77
79
  "test:ui": "vitest --ui",
@@ -84,7 +86,7 @@
84
86
  "@inquirer/core": "^10.1.13",
85
87
  "@inquirer/figures": "^1.0.12",
86
88
  "@inquirer/type": "^3.0.7",
87
- "ansi-escapes": "^4.3.2",
89
+ "ansi-escapes": "^7.0.0",
88
90
  "yoctocolors-cjs": "^2.1.2"
89
91
  },
90
92
  "devDependencies": {
@@ -93,15 +95,15 @@
93
95
  "@types/node": "^20.0.0",
94
96
  "@typescript-eslint/eslint-plugin": "^8.0.0",
95
97
  "@typescript-eslint/parser": "^8.0.0",
96
- "@vitest/coverage-v8": "^2.0.0",
97
- "@vitest/ui": "^2.0.0",
98
+ "@vitest/coverage-v8": "^3.2.3",
99
+ "@vitest/ui": "^3.2.3",
98
100
  "eslint": "^9.0.0",
99
101
  "eslint-config-prettier": "^9.0.0",
100
102
  "prettier": "^3.0.0",
101
103
  "rimraf": "^6.0.0",
102
104
  "tshy": "^3.0.2",
103
105
  "typescript": "^5.6.0",
104
- "vitest": "^2.0.0"
106
+ "vitest": "^3.2.3"
105
107
  },
106
108
  "engines": {
107
109
  "node": ">=18"