inquirerjs-checkbox-search 0.1.2 → 0.2.1

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` | No | Fixed number of choices to display. 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
 
@@ -44,6 +44,12 @@ export type NormalizedChoice<Value> = {
44
44
  disabled: boolean | string;
45
45
  checked: boolean;
46
46
  };
47
+ /**
48
+ * Calculate dynamic page size based on terminal height
49
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
50
+ * @returns Calculated page size
51
+ */
52
+ export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
47
53
  /**
48
54
  * Main checkbox-search prompt implementation
49
55
  *
@@ -4,6 +4,7 @@ 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.calculateDynamicPageSize = calculateDynamicPageSize;
7
8
  const core_1 = require("@inquirer/core");
8
9
  const yoctocolors_cjs_1 = __importDefault(require("yoctocolors-cjs"));
9
10
  const figures_1 = __importDefault(require("@inquirer/figures"));
@@ -24,7 +25,7 @@ const checkboxSearchTheme = {
24
25
  help: yoctocolors_cjs_1.default.dim,
25
26
  highlight: yoctocolors_cjs_1.default.cyan,
26
27
  searchTerm: yoctocolors_cjs_1.default.cyan,
27
- description: yoctocolors_cjs_1.default.dim,
28
+ description: yoctocolors_cjs_1.default.cyan,
28
29
  disabled: yoctocolors_cjs_1.default.dim,
29
30
  },
30
31
  helpMode: 'always',
@@ -104,6 +105,40 @@ function defaultFilter(items, term) {
104
105
  value.includes(searchTerm));
105
106
  });
106
107
  }
108
+ /**
109
+ * Calculate dynamic page size based on terminal height
110
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
111
+ * @returns Calculated page size
112
+ */
113
+ function calculateDynamicPageSize(fallbackPageSize) {
114
+ let rawPageSize;
115
+ try {
116
+ // Get terminal height from process.stdout.rows
117
+ const terminalHeight = process.stdout.rows;
118
+ if (!terminalHeight || terminalHeight < 1) {
119
+ // Fallback to static page size if terminal height is not available
120
+ rawPageSize = fallbackPageSize;
121
+ }
122
+ else {
123
+ // Reserve space for UI elements:
124
+ // - 1 line for the prompt message
125
+ // - 1 line for help instructions
126
+ // - 1 line for search input (if present)
127
+ // - 1 line for error messages (if present)
128
+ // - 1 line for description (if present)
129
+ // - 1 line for buffer/spacing
130
+ const reservedLines = 6;
131
+ // Calculate available lines for choices
132
+ rawPageSize = terminalHeight - reservedLines;
133
+ }
134
+ }
135
+ catch {
136
+ // If there's any error accessing terminal dimensions, fallback gracefully
137
+ rawPageSize = fallbackPageSize;
138
+ }
139
+ // Ensure minimum page size for usability and cap maximum to prevent overwhelming display
140
+ return Math.max(2, Math.min(rawPageSize, 50));
141
+ }
107
142
  /**
108
143
  * Main checkbox-search prompt implementation
109
144
  *
@@ -124,7 +159,15 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
124
159
  // Stable reference for empty array to prevent unnecessary recalculations
125
160
  const emptyArray = (0, core_1.useMemo)(() => [], []);
126
161
  // Configuration with defaults
127
- const { pageSize = 7, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
162
+ const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
163
+ // Calculate effective page size (memoized with terminal size tracking)
164
+ // If pageSize is specified, use it as fixed size
165
+ // If not specified, use auto-sizing that recalculates when terminal resizes
166
+ const terminalHeight = process.stdout.rows; // Track terminal size for memoization
167
+ const pageSize = (0, core_1.useMemo)(() => configPageSize !== undefined
168
+ ? configPageSize // Fixed page size
169
+ : calculateDynamicPageSize(7), // Auto page size with fallback 7
170
+ [configPageSize, terminalHeight]);
128
171
  const theme = (0, core_1.makeTheme)(checkboxSearchTheme, config.theme);
129
172
  // State management hooks
130
173
  const [status, setStatus] = (0, core_1.useState)('idle');
@@ -147,6 +190,8 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
147
190
  }
148
191
  return [];
149
192
  });
193
+ // Store the active item value instead of active index
194
+ const [activeItemValue, setActiveItemValue] = (0, core_1.useState)(null);
150
195
  // Compute filtered items based on search term and filtering logic
151
196
  const filteredItems = (0, core_1.useMemo)(() => {
152
197
  // Async source mode - use allItems directly (source handles filtering)
@@ -175,8 +220,48 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
175
220
  }
176
221
  return result;
177
222
  }, [allItems, searchTerm, config.source, config.filter]);
178
- const [active, setActive] = (0, core_1.useState)(0);
223
+ // Compute active index from activeItemValue
224
+ const active = (0, core_1.useMemo)(() => {
225
+ if (activeItemValue === null) {
226
+ // No active item set, default to first selectable
227
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
228
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
229
+ }
230
+ // Find the item with the active value
231
+ const activeIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
232
+ item.value === activeItemValue);
233
+ if (activeIndex !== -1) {
234
+ return activeIndex;
235
+ }
236
+ // Active item not found in filtered list, default to first selectable
237
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
238
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
239
+ }, [filteredItems, activeItemValue]);
240
+ // Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
241
+ (0, core_1.useEffect)(() => {
242
+ const activeItem = filteredItems[active];
243
+ if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
244
+ const currentActiveValue = activeItem
245
+ .value;
246
+ if (activeItemValue !== currentActiveValue) {
247
+ setActiveItemValue(currentActiveValue);
248
+ }
249
+ }
250
+ }, [active, filteredItems, activeItemValue]);
179
251
  const [errorMsg, setError] = (0, core_1.useState)();
252
+ // Hide cursor on mount, show on unmount (like other inquirer prompts)
253
+ (0, core_1.useEffect)(() => {
254
+ // Hide cursor when prompt starts (only in TTY environments)
255
+ if (process.stdout.isTTY) {
256
+ process.stdout.write(ansi_escapes_1.default.cursorHide);
257
+ }
258
+ // Show cursor when prompt ends (cleanup function)
259
+ return () => {
260
+ if (process.stdout.isTTY) {
261
+ process.stdout.write(ansi_escapes_1.default.cursorShow);
262
+ }
263
+ };
264
+ }, []);
180
265
  // Handle async source - load data based on search term
181
266
  (0, core_1.useEffect)(() => {
182
267
  if (!config.source) {
@@ -213,31 +298,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
213
298
  (0, core_1.useEffect)(() => {
214
299
  allItemsRef.current = allItems;
215
300
  }, [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
301
  // Keyboard event handling
240
302
  (0, core_1.useKeypress)((key, rl) => {
303
+ // Helper function to update search term in both readline and React state
304
+ const updateSearchTerm = (newTerm) => {
305
+ rl.clearLine(0);
306
+ rl.write(newTerm);
307
+ setSearchTerm(newTerm);
308
+ };
241
309
  // Allow search input even during loading, but block other actions
242
310
  const isNavigationOrAction = key.name === 'up' ||
243
311
  key.name === 'down' ||
@@ -251,13 +319,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
251
319
  setError(undefined);
252
320
  // Handle Escape key - clear search term quickly
253
321
  if (key.name === 'escape') {
254
- rl.line = ''; // Clear readline input first (avoid re-render)
255
- setSearchTerm(''); // Then update state
322
+ updateSearchTerm(''); // Clear both readline and React state
256
323
  return;
257
324
  }
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;
325
+ // Handle navigation - ONLY actual arrow keys (not vim j/k keys)
326
+ // This follows the official inquirer.js search approach
327
+ if (key.name === 'up' || key.name === 'down') {
328
+ rl.clearLine(0); // Clean readline state before navigation
329
+ const direction = key.name === 'up' ? -1 : 1;
261
330
  const selectableIndexes = filteredItems
262
331
  .map((item, index) => ({ item, index }))
263
332
  .filter(({ item }) => isSelectable(item))
@@ -275,14 +344,22 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
275
344
  else {
276
345
  nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
277
346
  }
278
- setActive(selectableIndexes[nextSelectableIndex] || 0);
347
+ // Translate from position inside `selectableIndexes` to the real index
348
+ const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
349
+ const nextSelectableItem = filteredItems[nextFilteredIndex];
350
+ if (nextSelectableItem && isSelectable(nextSelectableItem)) {
351
+ setActiveItemValue(nextSelectableItem.value);
352
+ }
279
353
  return;
280
354
  }
281
- // Handle selection toggle with tab key ONLY - prevent tab from affecting search text
355
+ // Handle selection toggle with tab key
282
356
  if (key.name === 'tab') {
357
+ const preservedSearchTerm = searchTerm;
283
358
  const activeItem = filteredItems[active];
284
359
  if (activeItem && isSelectable(activeItem)) {
285
360
  const activeValue = activeItem.value;
361
+ // Set this as the active item value so cursor position is preserved
362
+ setActiveItemValue(activeValue);
286
363
  setAllItems(allItems.map((item) => {
287
364
  // Compare by value only for robust matching
288
365
  if (!core_1.Separator.isSeparator(item) && item.value === activeValue) {
@@ -292,7 +369,10 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
292
369
  return item;
293
370
  }));
294
371
  }
295
- return; // Important: return here to prevent tab from being added to search term
372
+ updateSearchTerm(preservedSearchTerm);
373
+ // return to prevent tab from affecting search text:
374
+ // Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
375
+ return;
296
376
  }
297
377
  // Handle submission
298
378
  if ((0, core_1.isEnterKey)(key)) {
@@ -336,9 +416,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
336
416
  // Handle all other input as search term updates EXCEPT tab
337
417
  // Only update search term for actual typing, not navigation keys
338
418
  if (!isNavigationOrAction) {
419
+ // For general input, only update React state since rl.line is already current
339
420
  setSearchTerm(rl.line);
340
421
  }
341
422
  });
423
+ // Calculate the active item's description using useMemo for better React patterns
424
+ const activeDescription = (0, core_1.useMemo)(() => {
425
+ const activeItem = filteredItems[active];
426
+ if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
427
+ return activeItem.description;
428
+ }
429
+ return undefined;
430
+ }, [active, filteredItems]);
342
431
  // Create renderItem function that's reactive to current state
343
432
  const renderItem = (0, core_1.useMemo)(() => {
344
433
  return ({ item, isActive }) => {
@@ -363,32 +452,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
363
452
  let text = item.name;
364
453
  if (isActive) {
365
454
  text = theme.style.highlight(text);
455
+ // NOTE: Description is now calculated via useMemo, not side-effect mutation
366
456
  }
367
457
  else if (item.disabled) {
368
458
  text = theme.style.disabled(text);
369
459
  }
370
460
  line.push(text);
371
- // Show disabled reason if item is disabled
461
+ // Show disabled reason if item is disabled (but no descriptions inline anymore)
372
462
  if (item.disabled) {
373
463
  const disabledReason = typeof item.disabled === 'string'
374
464
  ? item.disabled
375
465
  : 'disabled';
376
466
  line.push(theme.style.disabled(`(${disabledReason})`));
377
467
  }
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
- }
468
+ // NOTE: Removed the inline description display - descriptions now appear at bottom
392
469
  return line.join(' ');
393
470
  };
394
471
  }, [allItems, theme, config.theme]);
@@ -403,12 +480,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
403
480
  // Render the prompt
404
481
  const message = theme.style.message(config.message, status);
405
482
  let helpTip = '';
406
- if (theme.helpMode === 'always') {
407
- const tips = ['Tab to select', 'Enter to submit'];
408
- helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
483
+ if (theme.helpMode === 'always' && config.instructions !== false) {
484
+ if (typeof config.instructions === 'string') {
485
+ helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
486
+ }
487
+ else {
488
+ const tips = ['Tab to select', 'Enter to submit'];
489
+ helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
490
+ }
409
491
  }
410
492
  let searchLine = '';
411
- if (config.source || searchTerm || status === 'loading') {
493
+ // Always show search line when search functionality is available (static choices or async source)
494
+ if (config.source || config.choices || searchTerm || status === 'loading') {
412
495
  const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
413
496
  const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
414
497
  searchLine = `\n${searchPrefix} ${styledTerm}`;
@@ -430,7 +513,12 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
430
513
  else {
431
514
  content = `\n${page}`;
432
515
  }
433
- return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${ansi_escapes_1.default.cursorHide}`;
516
+ // Add description of active item at the bottom (like original inquirer.js)
517
+ let descriptionLine = '';
518
+ if (activeDescription) {
519
+ descriptionLine = `\n${theme.style.description(activeDescription)}`;
520
+ }
521
+ return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
434
522
  });
435
523
  // Re-export Separator for convenience
436
524
  var core_2 = require("@inquirer/core");
@@ -44,6 +44,12 @@ export type NormalizedChoice<Value> = {
44
44
  disabled: boolean | string;
45
45
  checked: boolean;
46
46
  };
47
+ /**
48
+ * Calculate dynamic page size based on terminal height
49
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
50
+ * @returns Calculated page size
51
+ */
52
+ export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
47
53
  /**
48
54
  * Main checkbox-search prompt implementation
49
55
  *
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,40 @@ function defaultFilter(items, term) {
98
98
  value.includes(searchTerm));
99
99
  });
100
100
  }
101
+ /**
102
+ * Calculate dynamic page size based on terminal height
103
+ * @param fallbackPageSize - Default page size to use if terminal height is not available
104
+ * @returns Calculated page size
105
+ */
106
+ export function calculateDynamicPageSize(fallbackPageSize) {
107
+ let rawPageSize;
108
+ try {
109
+ // Get terminal height from process.stdout.rows
110
+ const terminalHeight = process.stdout.rows;
111
+ if (!terminalHeight || terminalHeight < 1) {
112
+ // Fallback to static page size if terminal height is not available
113
+ rawPageSize = fallbackPageSize;
114
+ }
115
+ else {
116
+ // Reserve space for UI elements:
117
+ // - 1 line for the prompt message
118
+ // - 1 line for help instructions
119
+ // - 1 line for search input (if present)
120
+ // - 1 line for error messages (if present)
121
+ // - 1 line for description (if present)
122
+ // - 1 line for buffer/spacing
123
+ const reservedLines = 6;
124
+ // Calculate available lines for choices
125
+ rawPageSize = terminalHeight - reservedLines;
126
+ }
127
+ }
128
+ catch {
129
+ // If there's any error accessing terminal dimensions, fallback gracefully
130
+ rawPageSize = fallbackPageSize;
131
+ }
132
+ // Ensure minimum page size for usability and cap maximum to prevent overwhelming display
133
+ return Math.max(2, Math.min(rawPageSize, 50));
134
+ }
101
135
  /**
102
136
  * Main checkbox-search prompt implementation
103
137
  *
@@ -118,7 +152,15 @@ export default createPrompt((config, done) => {
118
152
  // Stable reference for empty array to prevent unnecessary recalculations
119
153
  const emptyArray = useMemo(() => [], []);
120
154
  // Configuration with defaults
121
- const { pageSize = 7, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
155
+ const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
156
+ // Calculate effective page size (memoized with terminal size tracking)
157
+ // If pageSize is specified, use it as fixed size
158
+ // If not specified, use auto-sizing that recalculates when terminal resizes
159
+ const terminalHeight = process.stdout.rows; // Track terminal size for memoization
160
+ const pageSize = useMemo(() => configPageSize !== undefined
161
+ ? configPageSize // Fixed page size
162
+ : calculateDynamicPageSize(7), // Auto page size with fallback 7
163
+ [configPageSize, terminalHeight]);
122
164
  const theme = makeTheme(checkboxSearchTheme, config.theme);
123
165
  // State management hooks
124
166
  const [status, setStatus] = useState('idle');
@@ -141,6 +183,8 @@ export default createPrompt((config, done) => {
141
183
  }
142
184
  return [];
143
185
  });
186
+ // Store the active item value instead of active index
187
+ const [activeItemValue, setActiveItemValue] = useState(null);
144
188
  // Compute filtered items based on search term and filtering logic
145
189
  const filteredItems = useMemo(() => {
146
190
  // Async source mode - use allItems directly (source handles filtering)
@@ -169,8 +213,48 @@ export default createPrompt((config, done) => {
169
213
  }
170
214
  return result;
171
215
  }, [allItems, searchTerm, config.source, config.filter]);
172
- const [active, setActive] = useState(0);
216
+ // Compute active index from activeItemValue
217
+ const active = useMemo(() => {
218
+ if (activeItemValue === null) {
219
+ // No active item set, default to first selectable
220
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
221
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
222
+ }
223
+ // Find the item with the active value
224
+ const activeIndex = filteredItems.findIndex((item) => !Separator.isSeparator(item) &&
225
+ item.value === activeItemValue);
226
+ if (activeIndex !== -1) {
227
+ return activeIndex;
228
+ }
229
+ // Active item not found in filtered list, default to first selectable
230
+ const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
231
+ return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
232
+ }, [filteredItems, activeItemValue]);
233
+ // Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
234
+ useEffect(() => {
235
+ const activeItem = filteredItems[active];
236
+ if (activeItem && !Separator.isSeparator(activeItem)) {
237
+ const currentActiveValue = activeItem
238
+ .value;
239
+ if (activeItemValue !== currentActiveValue) {
240
+ setActiveItemValue(currentActiveValue);
241
+ }
242
+ }
243
+ }, [active, filteredItems, activeItemValue]);
173
244
  const [errorMsg, setError] = useState();
245
+ // Hide cursor on mount, show on unmount (like other inquirer prompts)
246
+ useEffect(() => {
247
+ // Hide cursor when prompt starts (only in TTY environments)
248
+ if (process.stdout.isTTY) {
249
+ process.stdout.write(ansiEscapes.cursorHide);
250
+ }
251
+ // Show cursor when prompt ends (cleanup function)
252
+ return () => {
253
+ if (process.stdout.isTTY) {
254
+ process.stdout.write(ansiEscapes.cursorShow);
255
+ }
256
+ };
257
+ }, []);
174
258
  // Handle async source - load data based on search term
175
259
  useEffect(() => {
176
260
  if (!config.source) {
@@ -207,31 +291,14 @@ export default createPrompt((config, done) => {
207
291
  useEffect(() => {
208
292
  allItemsRef.current = allItems;
209
293
  }, [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
294
  // Keyboard event handling
234
295
  useKeypress((key, rl) => {
296
+ // Helper function to update search term in both readline and React state
297
+ const updateSearchTerm = (newTerm) => {
298
+ rl.clearLine(0);
299
+ rl.write(newTerm);
300
+ setSearchTerm(newTerm);
301
+ };
235
302
  // Allow search input even during loading, but block other actions
236
303
  const isNavigationOrAction = key.name === 'up' ||
237
304
  key.name === 'down' ||
@@ -245,13 +312,14 @@ export default createPrompt((config, done) => {
245
312
  setError(undefined);
246
313
  // Handle Escape key - clear search term quickly
247
314
  if (key.name === 'escape') {
248
- rl.line = ''; // Clear readline input first (avoid re-render)
249
- setSearchTerm(''); // Then update state
315
+ updateSearchTerm(''); // Clear both readline and React state
250
316
  return;
251
317
  }
252
- // Handle navigation
253
- if (isUpKey(key) || isDownKey(key)) {
254
- const direction = isUpKey(key) ? -1 : 1;
318
+ // Handle navigation - ONLY actual arrow keys (not vim j/k keys)
319
+ // This follows the official inquirer.js search approach
320
+ if (key.name === 'up' || key.name === 'down') {
321
+ rl.clearLine(0); // Clean readline state before navigation
322
+ const direction = key.name === 'up' ? -1 : 1;
255
323
  const selectableIndexes = filteredItems
256
324
  .map((item, index) => ({ item, index }))
257
325
  .filter(({ item }) => isSelectable(item))
@@ -269,14 +337,22 @@ export default createPrompt((config, done) => {
269
337
  else {
270
338
  nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
271
339
  }
272
- setActive(selectableIndexes[nextSelectableIndex] || 0);
340
+ // Translate from position inside `selectableIndexes` to the real index
341
+ const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
342
+ const nextSelectableItem = filteredItems[nextFilteredIndex];
343
+ if (nextSelectableItem && isSelectable(nextSelectableItem)) {
344
+ setActiveItemValue(nextSelectableItem.value);
345
+ }
273
346
  return;
274
347
  }
275
- // Handle selection toggle with tab key ONLY - prevent tab from affecting search text
348
+ // Handle selection toggle with tab key
276
349
  if (key.name === 'tab') {
350
+ const preservedSearchTerm = searchTerm;
277
351
  const activeItem = filteredItems[active];
278
352
  if (activeItem && isSelectable(activeItem)) {
279
353
  const activeValue = activeItem.value;
354
+ // Set this as the active item value so cursor position is preserved
355
+ setActiveItemValue(activeValue);
280
356
  setAllItems(allItems.map((item) => {
281
357
  // Compare by value only for robust matching
282
358
  if (!Separator.isSeparator(item) && item.value === activeValue) {
@@ -286,7 +362,10 @@ export default createPrompt((config, done) => {
286
362
  return item;
287
363
  }));
288
364
  }
289
- return; // Important: return here to prevent tab from being added to search term
365
+ updateSearchTerm(preservedSearchTerm);
366
+ // return to prevent tab from affecting search text:
367
+ // Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
368
+ return;
290
369
  }
291
370
  // Handle submission
292
371
  if (isEnterKey(key)) {
@@ -330,9 +409,18 @@ export default createPrompt((config, done) => {
330
409
  // Handle all other input as search term updates EXCEPT tab
331
410
  // Only update search term for actual typing, not navigation keys
332
411
  if (!isNavigationOrAction) {
412
+ // For general input, only update React state since rl.line is already current
333
413
  setSearchTerm(rl.line);
334
414
  }
335
415
  });
416
+ // Calculate the active item's description using useMemo for better React patterns
417
+ const activeDescription = useMemo(() => {
418
+ const activeItem = filteredItems[active];
419
+ if (activeItem && !Separator.isSeparator(activeItem)) {
420
+ return activeItem.description;
421
+ }
422
+ return undefined;
423
+ }, [active, filteredItems]);
336
424
  // Create renderItem function that's reactive to current state
337
425
  const renderItem = useMemo(() => {
338
426
  return ({ item, isActive }) => {
@@ -357,32 +445,20 @@ export default createPrompt((config, done) => {
357
445
  let text = item.name;
358
446
  if (isActive) {
359
447
  text = theme.style.highlight(text);
448
+ // NOTE: Description is now calculated via useMemo, not side-effect mutation
360
449
  }
361
450
  else if (item.disabled) {
362
451
  text = theme.style.disabled(text);
363
452
  }
364
453
  line.push(text);
365
- // Show disabled reason if item is disabled
454
+ // Show disabled reason if item is disabled (but no descriptions inline anymore)
366
455
  if (item.disabled) {
367
456
  const disabledReason = typeof item.disabled === 'string'
368
457
  ? item.disabled
369
458
  : 'disabled';
370
459
  line.push(theme.style.disabled(`(${disabledReason})`));
371
460
  }
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
- }
461
+ // NOTE: Removed the inline description display - descriptions now appear at bottom
386
462
  return line.join(' ');
387
463
  };
388
464
  }, [allItems, theme, config.theme]);
@@ -397,12 +473,18 @@ export default createPrompt((config, done) => {
397
473
  // Render the prompt
398
474
  const message = theme.style.message(config.message, status);
399
475
  let helpTip = '';
400
- if (theme.helpMode === 'always') {
401
- const tips = ['Tab to select', 'Enter to submit'];
402
- helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
476
+ if (theme.helpMode === 'always' && config.instructions !== false) {
477
+ if (typeof config.instructions === 'string') {
478
+ helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
479
+ }
480
+ else {
481
+ const tips = ['Tab to select', 'Enter to submit'];
482
+ helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
483
+ }
403
484
  }
404
485
  let searchLine = '';
405
- if (config.source || searchTerm || status === 'loading') {
486
+ // Always show search line when search functionality is available (static choices or async source)
487
+ if (config.source || config.choices || searchTerm || status === 'loading') {
406
488
  const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
407
489
  const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
408
490
  searchLine = `\n${searchPrefix} ${styledTerm}`;
@@ -424,7 +506,12 @@ export default createPrompt((config, done) => {
424
506
  else {
425
507
  content = `\n${page}`;
426
508
  }
427
- return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${ansiEscapes.cursorHide}`;
509
+ // Add description of active item at the bottom (like original inquirer.js)
510
+ let descriptionLine = '';
511
+ if (activeDescription) {
512
+ descriptionLine = `\n${theme.style.description(activeDescription)}`;
513
+ }
514
+ return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
428
515
  });
429
516
  // Re-export Separator for convenience
430
517
  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.2.1",
4
4
  "description": "A multi-select prompt with text filtering for inquirer.js",
5
5
  "keywords": [
6
6
  "answer",
@@ -64,7 +64,7 @@
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",
@@ -84,7 +84,7 @@
84
84
  "@inquirer/core": "^10.1.13",
85
85
  "@inquirer/figures": "^1.0.12",
86
86
  "@inquirer/type": "^3.0.7",
87
- "ansi-escapes": "^4.3.2",
87
+ "ansi-escapes": "^7.0.0",
88
88
  "yoctocolors-cjs": "^2.1.2"
89
89
  },
90
90
  "devDependencies": {
@@ -93,15 +93,15 @@
93
93
  "@types/node": "^20.0.0",
94
94
  "@typescript-eslint/eslint-plugin": "^8.0.0",
95
95
  "@typescript-eslint/parser": "^8.0.0",
96
- "@vitest/coverage-v8": "^2.0.0",
97
- "@vitest/ui": "^2.0.0",
96
+ "@vitest/coverage-v8": "^3.2.3",
97
+ "@vitest/ui": "^3.2.3",
98
98
  "eslint": "^9.0.0",
99
99
  "eslint-config-prettier": "^9.0.0",
100
100
  "prettier": "^3.0.0",
101
101
  "rimraf": "^6.0.0",
102
102
  "tshy": "^3.0.2",
103
103
  "typescript": "^5.6.0",
104
- "vitest": "^2.0.0"
104
+ "vitest": "^3.2.3"
105
105
  },
106
106
  "engines": {
107
107
  "node": ">=18"