@tooee/choose 0.7.2 → 0.8.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.
Files changed (45) hide show
  1. package/dist/choose-filter.d.ts.map +1 -1
  2. package/dist/choose-filter.js +11 -2
  3. package/dist/choose-filter.js.map +1 -1
  4. package/dist/choose-list.d.ts.map +1 -1
  5. package/dist/choose-list.js +10 -1
  6. package/dist/choose-list.js.map +1 -1
  7. package/dist/choose-panel.d.ts +7 -9
  8. package/dist/choose-panel.d.ts.map +1 -1
  9. package/dist/choose-panel.js +5 -8
  10. package/dist/choose-panel.js.map +1 -1
  11. package/dist/choose-store.d.ts +57 -0
  12. package/dist/choose-store.d.ts.map +1 -0
  13. package/dist/choose-store.js +102 -0
  14. package/dist/choose-store.js.map +1 -0
  15. package/dist/choose.d.ts +5 -5
  16. package/dist/choose.d.ts.map +1 -1
  17. package/dist/choose.js +26 -12
  18. package/dist/choose.js.map +1 -1
  19. package/dist/fuzzy.d.ts.map +1 -1
  20. package/dist/fuzzy.js +2 -14
  21. package/dist/fuzzy.js.map +1 -1
  22. package/dist/launch.d.ts.map +1 -1
  23. package/dist/launch.js +1 -1
  24. package/dist/launch.js.map +1 -1
  25. package/dist/source.d.ts.map +1 -1
  26. package/dist/source.js +17 -5
  27. package/dist/source.js.map +1 -1
  28. package/dist/use-choose-dialog.d.ts.map +1 -1
  29. package/dist/use-choose-dialog.js +22 -84
  30. package/dist/use-choose-dialog.js.map +1 -1
  31. package/dist/use-choose.d.ts +1 -1
  32. package/dist/use-choose.d.ts.map +1 -1
  33. package/dist/use-choose.js +236 -264
  34. package/dist/use-choose.js.map +1 -1
  35. package/package.json +10 -7
  36. package/src/choose-filter.tsx +10 -2
  37. package/src/choose-list.tsx +9 -1
  38. package/src/choose-panel.tsx +18 -71
  39. package/src/choose-store.ts +146 -0
  40. package/src/choose.tsx +43 -27
  41. package/src/fuzzy.ts +2 -17
  42. package/src/launch.tsx +7 -2
  43. package/src/source.ts +24 -6
  44. package/src/use-choose-dialog.tsx +30 -98
  45. package/src/use-choose.ts +273 -278
package/src/use-choose.ts CHANGED
@@ -1,16 +1,28 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import type { InputRenderable, KeyEvent } from "@opentui/core";
3
+ import { useSelector } from "@xstate/store-react";
3
4
  import {
4
5
  useActiveCommandSurface,
5
6
  useActions,
6
- useCommand,
7
7
  useCommandSurfaceId,
8
+ useLatest,
9
+ useLazyRef,
8
10
  useMode,
9
11
  useProvideCommandContext,
10
12
  useSetMode,
11
13
  } from "@tooee/commands";
12
- import type { ActionDefinition, Mode } from "@tooee/commands";
13
- import { fuzzyFilter } from "./fuzzy.js";
14
+ import type { ActionDefinition, CommandContext, Mode } from "@tooee/commands";
15
+ import {
16
+ createChooseStore,
17
+ selectActiveIndex,
18
+ selectError,
19
+ selectFilterQuery,
20
+ selectItems,
21
+ selectLoading,
22
+ selectMatches,
23
+ selectReloadRevision,
24
+ selectSelectedOriginalIndices,
25
+ } from "./choose-store.js";
14
26
  import type { FuzzyMatch } from "./fuzzy.js";
15
27
  import { chooseSourceError, loadChooseSource } from "./source.js";
16
28
  import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js";
@@ -18,7 +30,7 @@ import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js";
18
30
  declare module "@tooee/commands" {
19
31
  interface CommandContext {
20
32
  /** Contributed by chooser primitives: current filtering and selection state. */
21
- choose: {
33
+ choose?: {
22
34
  activeItem: ChooseItem | undefined;
23
35
  selectedItems: ChooseItem[];
24
36
  filterQuery: string;
@@ -90,6 +102,16 @@ export interface UseChooseResult {
90
102
  view: ChooseViewModel;
91
103
  }
92
104
 
105
+ interface ChooseCommandContextContribution {
106
+ choose: CommandContext["choose"];
107
+ exit?: () => void;
108
+ }
109
+
110
+ interface ChooseKeymapDefinition extends Omit<ActionDefinition, "group" | "when"> {
111
+ group: () => ChooseCommandGroup;
112
+ when?: () => boolean;
113
+ }
114
+
93
115
  /**
94
116
  * Shared headless chooser: source lifecycle, fuzzy matches, selection,
95
117
  * command context, controller, and all built-in keyboard commands.
@@ -97,20 +119,29 @@ export interface UseChooseResult {
97
119
  export const useChoose = function useChoose(options: UseChooseOptions): UseChooseResult {
98
120
  const { source, multi = false, initialFilter = "", commandScope = "choose" } = options;
99
121
  const initialItems = Array.isArray(source) ? source : [];
100
-
101
- const [items, setItems] = useState<ChooseItem[]>(initialItems);
102
- const [filterQuery, setFilterQuery] = useState(initialFilter);
103
- const [activeIndex, setActiveIndex] = useState(0);
104
- const [selectedOriginalIndices, setSelectedOriginalIndices] = useState<Set<number>>(new Set());
105
- const [loading, setLoading] = useState(!Array.isArray(source));
106
- const [error, setError] = useState<string | null>(null);
107
- const [reloadRevision, setReloadRevision] = useState(0);
122
+ const store = useLazyRef(() =>
123
+ createChooseStore({
124
+ initialFilter,
125
+ items: initialItems,
126
+ loading: !Array.isArray(source),
127
+ }),
128
+ ).current;
108
129
  const filterRef = useRef<InputRenderable>(null);
109
- const requestIdRef = useRef(0);
110
- const initialArraySourceRef = useRef<ChooseItem[] | null>(Array.isArray(source) ? source : null);
130
+ const initialArraySourceRef = useLazyRef<ChooseItem[] | null>(() =>
131
+ Array.isArray(source) ? source : null,
132
+ );
111
133
  const didHandleInitialSourceRef = useRef(false);
112
134
 
113
- const matches = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery]);
135
+ const items = useSelector(store, (snapshot) => selectItems(snapshot.context));
136
+ const matches = useSelector(store, (snapshot) => selectMatches(snapshot.context));
137
+ const filterQuery = useSelector(store, (snapshot) => selectFilterQuery(snapshot.context));
138
+ const activeIndex = useSelector(store, (snapshot) => selectActiveIndex(snapshot.context));
139
+ const selectedOriginalIndices = useSelector(store, (snapshot) =>
140
+ selectSelectedOriginalIndices(snapshot.context),
141
+ );
142
+ const loading = useSelector(store, (snapshot) => selectLoading(snapshot.context));
143
+ const error = useSelector(store, (snapshot) => selectError(snapshot.context));
144
+ const reloadRevision = useSelector(store, (snapshot) => selectReloadRevision(snapshot.context));
114
145
  const activeItem = matches[activeIndex]?.item;
115
146
  const selectedItems = useMemo(() => {
116
147
  if (!multi) {
@@ -128,170 +159,108 @@ export const useChoose = function useChoose(options: UseChooseOptions): UseChoos
128
159
 
129
160
  const mode = useMode();
130
161
  const setMode = useSetMode();
131
-
132
- // Stable controller methods read live values through refs, including when
133
- // multiple imperative operations happen before React renders again.
134
- const optionsRef = useRef(options);
135
- optionsRef.current = options;
136
- const itemsRef = useRef(items);
137
- itemsRef.current = items;
138
- const matchesRef = useRef(matches);
139
- matchesRef.current = matches;
140
- const filterQueryRef = useRef(filterQuery);
141
- filterQueryRef.current = filterQuery;
142
- const activeIndexRef = useRef(activeIndex);
143
- activeIndexRef.current = activeIndex;
144
- const selectedRef = useRef(selectedOriginalIndices);
145
- selectedRef.current = selectedOriginalIndices;
146
- const multiRef = useRef(multi);
147
- multiRef.current = multi;
148
- const modeRef = useRef(mode);
149
- modeRef.current = mode;
150
-
151
- const replaceItems = useCallback((nextItems: ChooseItem[]) => {
152
- itemsRef.current = nextItems;
153
- matchesRef.current = fuzzyFilter(nextItems, filterQueryRef.current);
154
- activeIndexRef.current = 0;
155
- selectedRef.current = new Set();
156
- setItems(nextItems);
157
- setActiveIndex(0);
158
- setSelectedOriginalIndices(selectedRef.current);
159
- }, []);
162
+ const optionsRef = useLatest(options);
163
+ const multiRef = useLatest(multi);
164
+ const modeRef = useLatest(mode);
160
165
 
161
166
  useEffect(() => {
162
- requestIdRef.current += 1;
163
- const requestId = requestIdRef.current;
164
167
  let active = true;
165
168
  const deactivate = () => {
166
169
  active = false;
167
170
  };
168
- let result: ChooseItem[] | Promise<ChooseItem[]>;
169
171
 
170
- // Direct arrays seed state during the first render. Avoid a redundant
171
- // post-mount reset that could overwrite immediate controller operations.
172
+ // Direct arrays seed the store before the first render. Avoid replacing
173
+ // state after mount, which could overwrite immediate controller calls.
172
174
  if (
173
175
  !didHandleInitialSourceRef.current &&
174
176
  Array.isArray(source) &&
175
177
  source === initialArraySourceRef.current
176
178
  ) {
177
179
  didHandleInitialSourceRef.current = true;
178
- setLoading(false);
179
180
  return deactivate;
180
181
  }
181
182
  didHandleInitialSourceRef.current = true;
182
183
 
183
- setError(null);
184
+ store.trigger.requestStarted({});
185
+ const { requestId } = store.getSnapshot().context;
186
+ let result: ChooseItem[] | Promise<ChooseItem[]>;
184
187
  try {
185
188
  result = loadChooseSource(source);
186
189
  } catch (loadError) {
187
- if (requestId !== requestIdRef.current) {
188
- return deactivate;
189
- }
190
- replaceItems([]);
191
- setError(chooseSourceError(loadError));
192
- setLoading(false);
190
+ store.trigger.loadFailed({ error: chooseSourceError(loadError), requestId });
193
191
  return deactivate;
194
192
  }
195
193
 
196
194
  if (result instanceof Promise) {
197
- setLoading(true);
195
+ store.trigger.loadingStarted({ requestId });
198
196
  void (async () => {
199
197
  try {
200
198
  const loaded = await result;
201
- if (!active || requestId !== requestIdRef.current) {
202
- return;
199
+ if (active) {
200
+ store.trigger.loadSucceeded({ items: loaded, requestId });
203
201
  }
204
- replaceItems(loaded);
205
- setError(null);
206
- setLoading(false);
207
202
  } catch (loadError) {
208
- if (!active || requestId !== requestIdRef.current) {
209
- return;
203
+ if (active) {
204
+ store.trigger.loadFailed({ error: chooseSourceError(loadError), requestId });
210
205
  }
211
- replaceItems([]);
212
- setError(chooseSourceError(loadError));
213
- setLoading(false);
214
206
  }
215
207
  })();
216
208
  return deactivate;
217
209
  }
218
210
 
219
- replaceItems(result);
220
- setLoading(false);
211
+ store.trigger.loadSucceeded({ items: result, requestId });
221
212
  return deactivate;
222
- }, [source, reloadRevision, replaceItems]);
223
-
224
- const setFilter = useCallback((query: string) => {
225
- // Controlled OpenTUI inputs may echo a programmatic `value` update through
226
- // onInput. Only a real query change resets navigation.
227
- if (query === filterQueryRef.current) {
228
- setFilterQuery(query);
229
- return;
230
- }
231
- filterQueryRef.current = query;
232
- matchesRef.current = fuzzyFilter(itemsRef.current, query);
233
- activeIndexRef.current = 0;
234
- setFilterQuery(query);
235
- setActiveIndex(0);
236
- }, []);
237
-
238
- const updateActiveIndex = useCallback((index: number) => {
239
- const lastIndex = Math.max(0, matchesRef.current.length - 1);
240
- const next = Math.min(lastIndex, Math.max(0, index));
241
- activeIndexRef.current = next;
242
- setActiveIndex(next);
243
- }, []);
213
+ }, [source, reloadRevision, store, initialArraySourceRef]);
244
214
 
215
+ const setFilter = useCallback(
216
+ (query: string) => {
217
+ store.trigger.filterChanged({ query });
218
+ },
219
+ [store],
220
+ );
221
+ const updateActiveIndex = useCallback(
222
+ (index: number) => {
223
+ store.trigger.activeIndexSet({ index });
224
+ },
225
+ [store],
226
+ );
245
227
  const moveUp = useCallback(() => {
246
- updateActiveIndex(activeIndexRef.current - 1);
247
- }, [updateActiveIndex]);
228
+ store.trigger.moved({ delta: -1 });
229
+ }, [store]);
248
230
  const moveDown = useCallback(() => {
249
- updateActiveIndex(activeIndexRef.current + 1);
250
- }, [updateActiveIndex]);
251
-
252
- const getActiveItem = useCallback(() => matchesRef.current[activeIndexRef.current]?.item, []);
253
-
231
+ store.trigger.moved({ delta: 1 });
232
+ }, [store]);
233
+ const getActiveItem = useCallback(() => {
234
+ const { context } = store.getSnapshot();
235
+ return context.matches[context.activeIndex]?.item;
236
+ }, [store]);
254
237
  const getSelectedItems = useCallback((): ChooseItem[] => {
255
- const active = getActiveItem();
238
+ const { context } = store.getSnapshot();
239
+ const active = context.matches[context.activeIndex]?.item;
256
240
  if (!multiRef.current) {
257
241
  return active === undefined ? [] : [active];
258
242
  }
259
- const selected = [...selectedRef.current].flatMap((index) => {
260
- const item = itemsRef.current[index];
243
+ const selected = [...context.selectedOriginalIndices].flatMap((index) => {
244
+ const item = context.items[index];
261
245
  return item === undefined ? [] : [item];
262
246
  });
263
247
  if (selected.length > 0) {
264
248
  return selected;
265
249
  }
266
250
  return active === undefined ? [] : [active];
267
- }, [getActiveItem]);
268
-
251
+ }, [multiRef, store]);
269
252
  const toggleActive = useCallback(() => {
270
- if (!multiRef.current) {
271
- return;
253
+ if (multiRef.current) {
254
+ store.trigger.activeToggled({});
272
255
  }
273
- const originalIndex = matchesRef.current[activeIndexRef.current]?.originalIndex;
274
- if (originalIndex === undefined) {
275
- return;
276
- }
277
- const next = new Set(selectedRef.current);
278
- if (next.has(originalIndex)) {
279
- next.delete(originalIndex);
280
- } else {
281
- next.add(originalIndex);
282
- }
283
- selectedRef.current = next;
284
- setSelectedOriginalIndices(next);
285
- }, []);
286
-
256
+ }, [multiRef, store]);
287
257
  const submit = useCallback(() => {
288
258
  void optionsRef.current.onSubmit({ items: getSelectedItems() });
289
- }, [getSelectedItems]);
290
-
291
- const cancel = useCallback(() => optionsRef.current.onCancel?.(), []);
259
+ }, [getSelectedItems, optionsRef]);
260
+ const cancel = useCallback(() => optionsRef.current.onCancel?.(), [optionsRef]);
292
261
  const reload = useCallback(() => {
293
- setReloadRevision((revision) => revision + 1);
294
- }, []);
262
+ store.trigger.reloadRequested({});
263
+ }, [store]);
295
264
  const setModeExternal = useCallback(
296
265
  (nextMode: Mode) => {
297
266
  setMode(nextMode);
@@ -299,158 +268,185 @@ export const useChoose = function useChoose(options: UseChooseOptions): UseChoos
299
268
  [setMode],
300
269
  );
301
270
 
302
- useProvideCommandContext(() => ({
303
- choose: {
304
- activeItem: getActiveItem(),
305
- filterQuery: filterQueryRef.current,
306
- selectedItems: getSelectedItems(),
307
- },
308
- ...(optionsRef.current.onCancel ? { exit: cancel } : {}),
309
- }));
271
+ useProvideCommandContext(() => {
272
+ const context: ChooseCommandContextContribution = {
273
+ choose: {
274
+ activeItem: getActiveItem(),
275
+ filterQuery: store.getSnapshot().context.filterQuery,
276
+ selectedItems: getSelectedItems(),
277
+ },
278
+ };
279
+ if (optionsRef.current.onCancel !== undefined) {
280
+ context.exit = cancel;
281
+ }
282
+ return context;
283
+ });
310
284
 
311
285
  useActions(options.commands);
312
286
 
313
287
  const enabled = useCallback(
314
288
  (group: ChooseCommandGroup) => !(optionsRef.current.disable?.includes(group) ?? false),
315
- [],
289
+ [optionsRef],
316
290
  );
317
-
318
- useCommand({
319
- handler: () => {
320
- if (modeRef.current === "insert") {
321
- setMode("cursor");
322
- } else {
323
- cancel();
324
- }
325
- },
326
- hidden: true,
327
- hotkey: "Escape",
328
- id: `${commandScope}:escape`,
329
- modes: ["insert", "cursor"],
330
- title: "Back / cancel",
331
- when: () =>
332
- modeRef.current === "insert"
333
- ? enabled("mode")
334
- : enabled("cancel") && optionsRef.current.onCancel !== undefined,
335
- });
336
- useCommand({
337
- handler: cancel,
338
- hidden: true,
339
- hotkey: "q",
340
- id: `${commandScope}:cancel`,
341
- modes: ["cursor"],
342
- title: "Cancel",
343
- when: () => enabled("cancel") && optionsRef.current.onCancel !== undefined,
344
- });
345
- useCommand({
346
- handler: () => {
347
- setMode("insert");
348
- },
349
- hidden: true,
350
- hotkey: "i",
351
- id: `${commandScope}:insert-mode-i`,
352
- modes: ["cursor"],
353
- title: "Insert mode",
354
- when: () => enabled("mode"),
355
- });
356
- useCommand({
357
- handler: () => {
358
- setMode("insert");
359
- },
360
- hidden: true,
361
- hotkey: "a",
362
- id: `${commandScope}:insert-mode-a`,
363
- modes: ["cursor"],
364
- title: "Insert mode",
365
- when: () => enabled("mode"),
366
- });
367
- useCommand({
368
- handler: moveDown,
369
- hidden: true,
370
- hotkey: "j",
371
- id: `${commandScope}:move-down-vim`,
372
- modes: ["cursor"],
373
- title: "Move down",
374
- when: () => enabled("navigation"),
375
- });
376
- useCommand({
377
- handler: moveUp,
378
- hidden: true,
379
- hotkey: "k",
380
- id: `${commandScope}:move-up-vim`,
381
- modes: ["cursor"],
382
- title: "Move up",
383
- when: () => enabled("navigation"),
384
- });
385
- useCommand({
386
- handler: submit,
387
- hidden: true,
388
- hotkey: "Enter",
389
- id: `${commandScope}:confirm`,
390
- modes: ["insert", "cursor"],
391
- title: "Confirm",
392
- when: () => enabled("submit"),
393
- });
394
- useCommand({
395
- handler: moveUp,
396
- hidden: true,
397
- hotkey: "up",
398
- id: `${commandScope}:move-up`,
399
- modes: ["insert", "cursor"],
400
- title: "Move up",
401
- when: () => enabled("navigation"),
402
- });
403
- useCommand({
404
- handler: moveUp,
405
- hidden: true,
406
- hotkey: "ctrl+p",
407
- id: `${commandScope}:move-up-ctrl-p`,
408
- modes: ["insert", "cursor"],
409
- title: "Move up",
410
- when: () => enabled("navigation"),
411
- });
412
- useCommand({
413
- handler: moveDown,
414
- hidden: true,
415
- hotkey: "down",
416
- id: `${commandScope}:move-down`,
417
- modes: ["insert", "cursor"],
418
- title: "Move down",
419
- when: () => enabled("navigation"),
420
- });
421
- useCommand({
422
- handler: moveDown,
423
- hidden: true,
424
- hotkey: "ctrl+n",
425
- id: `${commandScope}:move-down-ctrl-n`,
426
- modes: ["insert", "cursor"],
427
- title: "Move down",
428
- when: () => enabled("navigation"),
429
- });
430
- useCommand({
431
- handler: () => {
432
- toggleActive();
433
- moveDown();
434
- },
435
- hidden: true,
436
- hotkey: "Tab",
437
- id: `${commandScope}:toggle-next`,
438
- modes: ["insert", "cursor"],
439
- title: "Toggle selection and move down",
440
- when: () => enabled("multi-select") && multiRef.current,
441
- });
442
- useCommand({
443
- handler: () => {
444
- toggleActive();
445
- moveUp();
446
- },
447
- hidden: true,
448
- hotkey: "shift+Tab",
449
- id: `${commandScope}:toggle-previous`,
450
- modes: ["insert", "cursor"],
451
- title: "Toggle selection and move up",
452
- when: () => enabled("multi-select") && multiRef.current,
453
- });
291
+ const builtInActions = useMemo<ActionDefinition[]>(() => {
292
+ const definitions: ChooseKeymapDefinition[] = [
293
+ {
294
+ group: () => (modeRef.current === "insert" ? "mode" : "cancel"),
295
+ handler: () => {
296
+ if (modeRef.current === "insert") {
297
+ setMode("cursor");
298
+ } else {
299
+ cancel();
300
+ }
301
+ },
302
+ hidden: true,
303
+ hotkey: "Escape",
304
+ id: `${commandScope}:escape`,
305
+ modes: ["insert", "cursor"],
306
+ title: "Back / cancel",
307
+ when: () =>
308
+ modeRef.current === "insert" ? true : optionsRef.current.onCancel !== undefined,
309
+ },
310
+ {
311
+ group: () => "cancel",
312
+ handler: cancel,
313
+ hidden: true,
314
+ hotkey: "q",
315
+ id: `${commandScope}:cancel`,
316
+ modes: ["cursor"],
317
+ title: "Cancel",
318
+ when: () => optionsRef.current.onCancel !== undefined,
319
+ },
320
+ {
321
+ group: () => "mode",
322
+ handler: () => {
323
+ setMode("insert");
324
+ },
325
+ hidden: true,
326
+ hotkey: "i",
327
+ id: `${commandScope}:insert-mode-i`,
328
+ modes: ["cursor"],
329
+ title: "Insert mode",
330
+ },
331
+ {
332
+ group: () => "mode",
333
+ handler: () => {
334
+ setMode("insert");
335
+ },
336
+ hidden: true,
337
+ hotkey: "a",
338
+ id: `${commandScope}:insert-mode-a`,
339
+ modes: ["cursor"],
340
+ title: "Insert mode",
341
+ },
342
+ {
343
+ group: () => "navigation",
344
+ handler: moveDown,
345
+ hidden: true,
346
+ hotkey: "j",
347
+ id: `${commandScope}:move-down-vim`,
348
+ modes: ["cursor"],
349
+ title: "Move down",
350
+ },
351
+ {
352
+ group: () => "navigation",
353
+ handler: moveUp,
354
+ hidden: true,
355
+ hotkey: "k",
356
+ id: `${commandScope}:move-up-vim`,
357
+ modes: ["cursor"],
358
+ title: "Move up",
359
+ },
360
+ {
361
+ group: () => "submit",
362
+ handler: submit,
363
+ hidden: true,
364
+ hotkey: "Enter",
365
+ id: `${commandScope}:confirm`,
366
+ modes: ["insert", "cursor"],
367
+ title: "Confirm",
368
+ },
369
+ {
370
+ group: () => "navigation",
371
+ handler: moveUp,
372
+ hidden: true,
373
+ hotkey: "up",
374
+ id: `${commandScope}:move-up`,
375
+ modes: ["insert", "cursor"],
376
+ title: "Move up",
377
+ },
378
+ {
379
+ group: () => "navigation",
380
+ handler: moveUp,
381
+ hidden: true,
382
+ hotkey: "ctrl+p",
383
+ id: `${commandScope}:move-up-ctrl-p`,
384
+ modes: ["insert", "cursor"],
385
+ title: "Move up",
386
+ },
387
+ {
388
+ group: () => "navigation",
389
+ handler: moveDown,
390
+ hidden: true,
391
+ hotkey: "down",
392
+ id: `${commandScope}:move-down`,
393
+ modes: ["insert", "cursor"],
394
+ title: "Move down",
395
+ },
396
+ {
397
+ group: () => "navigation",
398
+ handler: moveDown,
399
+ hidden: true,
400
+ hotkey: "ctrl+n",
401
+ id: `${commandScope}:move-down-ctrl-n`,
402
+ modes: ["insert", "cursor"],
403
+ title: "Move down",
404
+ },
405
+ {
406
+ group: () => "multi-select",
407
+ handler: () => {
408
+ toggleActive();
409
+ moveDown();
410
+ },
411
+ hidden: true,
412
+ hotkey: "Tab",
413
+ id: `${commandScope}:toggle-next`,
414
+ modes: ["insert", "cursor"],
415
+ title: "Toggle selection and move down",
416
+ when: () => multiRef.current,
417
+ },
418
+ {
419
+ group: () => "multi-select",
420
+ handler: () => {
421
+ toggleActive();
422
+ moveUp();
423
+ },
424
+ hidden: true,
425
+ hotkey: "shift+Tab",
426
+ id: `${commandScope}:toggle-previous`,
427
+ modes: ["insert", "cursor"],
428
+ title: "Toggle selection and move up",
429
+ when: () => multiRef.current,
430
+ },
431
+ ];
432
+ return definitions.map(({ group, when, ...definition }) => ({
433
+ ...definition,
434
+ when: () => enabled(group()) && (when?.() ?? true),
435
+ }));
436
+ }, [
437
+ cancel,
438
+ commandScope,
439
+ enabled,
440
+ modeRef,
441
+ moveDown,
442
+ moveUp,
443
+ multiRef,
444
+ optionsRef,
445
+ setMode,
446
+ submit,
447
+ toggleActive,
448
+ ]);
449
+ useActions(builtInActions);
454
450
 
455
451
  const surfaceId = useCommandSurfaceId();
456
452
  const activeSurface = useActiveCommandSurface();
@@ -475,17 +471,16 @@ export const useChoose = function useChoose(options: UseChooseOptions): UseChoos
475
471
  moveDown();
476
472
  }
477
473
  },
478
- [enabled, moveDown, moveUp, toggleActive],
474
+ [enabled, modeRef, moveDown, moveUp, multiRef, toggleActive],
479
475
  );
480
476
 
481
- const controllerRef = useRef<ChooseController | null>(null);
482
- controllerRef.current ??= {
477
+ const controller = useLazyRef<ChooseController>(() => ({
483
478
  cancel,
484
479
  clearFilter: () => {
485
480
  setFilter("");
486
481
  },
487
482
  getActiveItem,
488
- getFilter: () => filterQueryRef.current,
483
+ getFilter: () => store.getSnapshot().context.filterQuery,
489
484
  getSelectedItems,
490
485
  get mode() {
491
486
  return modeRef.current;
@@ -498,10 +493,10 @@ export const useChoose = function useChoose(options: UseChooseOptions): UseChoos
498
493
  setMode: setModeExternal,
499
494
  submit,
500
495
  toggleActive,
501
- };
496
+ })).current;
502
497
 
503
498
  return {
504
- controller: controllerRef.current,
499
+ controller,
505
500
  state: {
506
501
  activeIndex,
507
502
  activeItem,