@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tooee/choose",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Terminal selection with fuzzy filtering",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,16 +40,19 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@tooee/commands": "0.7.2",
44
- "@tooee/fuzzy": "0.7.2",
45
- "@tooee/layout": "0.7.2",
46
- "@tooee/overlays": "0.7.2",
47
- "@tooee/shell": "0.7.2",
48
- "@tooee/themes": "0.7.2"
43
+ "@tooee/commands": "0.8.0",
44
+ "@tooee/fuzzy": "0.8.0",
45
+ "@tooee/layout": "0.8.0",
46
+ "@tooee/overlays": "0.8.0",
47
+ "@tooee/shell": "0.8.0",
48
+ "@tooee/themes": "0.8.0",
49
+ "@xstate/store": "^4",
50
+ "@xstate/store-react": "^2"
49
51
  },
50
52
  "devDependencies": {
51
53
  "@opentui/core": "^0.5.1",
52
54
  "@opentui/react": "^0.5.1",
55
+ "@tooee/test-support": "0.7.3",
53
56
  "@types/bun": "^1.3.10",
54
57
  "@types/react": "^19.2.14",
55
58
  "typescript": "^5.9.3"
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
- import { CloseButton, useTheme } from "@tooee/themes";
2
+ import { CloseButton, decodeReactContent } from "@tooee/layout";
3
+ import { useTheme } from "@tooee/themes";
3
4
  import type { UseChooseResult } from "./use-choose.js";
4
5
 
5
6
  export interface ChooseFilterProps {
@@ -22,10 +23,17 @@ export const ChooseFilter = function ChooseFilter({
22
23
  const { state, view } = choose;
23
24
  const handleFilterInput = view.onFilterInput;
24
25
  const handleFilterKeyDown = view.onFilterKeyDown;
26
+ const promptContent = decodeReactContent(prompt);
27
+ let renderedPrompt: ReactNode = null;
28
+ if (promptContent.kind === "string") {
29
+ renderedPrompt = <text content={promptContent.value} fg={theme.accent} />;
30
+ } else if (promptContent.kind === "node") {
31
+ renderedPrompt = promptContent.value;
32
+ }
25
33
 
26
34
  return (
27
35
  <box flexDirection="row" height={1} style={{ paddingLeft: 1, paddingRight: 1 }}>
28
- {typeof prompt === "string" ? <text content={prompt} fg={theme.accent} /> : prompt}
36
+ {renderedPrompt}
29
37
  <input
30
38
  ref={view.filterRef}
31
39
  focused={view.filterFocused}
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { ReactNode } from "react";
3
+ import { decodeReactContent } from "@tooee/layout";
3
4
  import type { ScrollBoxRenderable } from "@opentui/core";
4
5
  import { useTheme } from "@tooee/themes";
5
6
  import { ChooseHighlightedText } from "./choose-highlighted-text.js";
@@ -37,9 +38,16 @@ const ThemedLine = function ThemedLine({
37
38
  content: ReactNode;
38
39
  color: string;
39
40
  }): ReactNode {
41
+ const decoded = decodeReactContent(content);
42
+ let rendered: ReactNode = null;
43
+ if (decoded.kind === "string") {
44
+ rendered = <text content={decoded.value} fg={color} />;
45
+ } else if (decoded.kind === "node") {
46
+ rendered = decoded.value;
47
+ }
40
48
  return (
41
49
  <box height={1} style={{ paddingLeft: 2 }}>
42
- {typeof content === "string" ? <text content={content} fg={color} /> : content}
50
+ {rendered}
43
51
  </box>
44
52
  );
45
53
  };
@@ -1,7 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { MouseEvent } from "@opentui/core";
2
3
  import { useMode } from "@tooee/commands";
3
4
  import type { Mode } from "@tooee/commands";
4
- import { CloseButton, useTheme } from "@tooee/themes";
5
+ import { OverlayPanel } from "@tooee/layout";
6
+ import type { PanelInset, PanelInsetValue } from "@tooee/layout";
5
7
 
6
8
  export const buildChooseHints = function buildChooseHints(
7
9
  mode: Mode,
@@ -25,14 +27,8 @@ export const buildChooseHints = function buildChooseHints(
25
27
  return options.extra ? [...base, ...options.extra] : base;
26
28
  };
27
29
 
28
- export type ChoosePanelInsetValue = number | "auto" | `${number}%`;
29
-
30
- export interface ChoosePanelInset {
31
- left?: ChoosePanelInsetValue;
32
- right?: ChoosePanelInsetValue;
33
- top?: ChoosePanelInsetValue;
34
- bottom?: ChoosePanelInsetValue;
35
- }
30
+ export type ChoosePanelInsetValue = PanelInsetValue;
31
+ export type ChoosePanelInset = PanelInset;
36
32
 
37
33
  export interface ChoosePanelProps {
38
34
  title?: ReactNode;
@@ -44,9 +40,10 @@ export interface ChoosePanelProps {
44
40
  footer?: ReactNode;
45
41
  onClose?: () => void;
46
42
  inset?: ChoosePanelInset;
43
+ onMouseDown?: (event: MouseEvent) => void;
47
44
  }
48
45
 
49
- /** Bordered chooser chrome with filter, content, hint/status, and footer slots. */
46
+ /** Chooser-specific overlay panel with mode-aware default hints. */
50
47
  export const ChoosePanel = function ChoosePanel({
51
48
  title,
52
49
  filter,
@@ -57,74 +54,24 @@ export const ChoosePanel = function ChoosePanel({
57
54
  footer,
58
55
  onClose,
59
56
  inset,
57
+ onMouseDown,
60
58
  }: ChoosePanelProps): ReactNode {
61
- const { theme } = useTheme();
62
59
  const mode = useMode();
63
60
  const defaults = buildChooseHints(mode, { multi });
64
61
  const hintContent = hints ? hints({ defaults, mode }) : defaults.join(" ");
65
62
 
66
63
  return (
67
- <box
68
- position="absolute"
69
- left={inset?.left ?? "20%"}
70
- right={inset?.right ?? "20%"}
71
- top={inset?.top ?? "20%"}
72
- bottom={inset?.bottom ?? "20%"}
73
- flexDirection="column"
74
- backgroundColor={theme.backgroundPanel}
75
- border
76
- borderColor={theme.borderActive}
64
+ <OverlayPanel
65
+ title={title}
66
+ prompt={filter}
67
+ hints={hintContent}
68
+ statusRight={statusRight}
69
+ footer={footer}
70
+ onClose={onClose}
71
+ inset={inset}
72
+ onMouseDown={onMouseDown}
77
73
  >
78
- {title !== null && title !== undefined && (
79
- <box
80
- flexDirection="row"
81
- height={1}
82
- paddingLeft={1}
83
- paddingRight={1}
84
- backgroundColor={theme.backgroundElement}
85
- >
86
- {typeof title === "string" ? (
87
- <text content={title} fg={theme.accent} style={{ flexGrow: 1 }} />
88
- ) : (
89
- <box flexDirection="row" style={{ flexGrow: 1 }}>
90
- {title}
91
- </box>
92
- )}
93
- {onClose && <CloseButton onClose={onClose} />}
94
- </box>
95
- )}
96
-
97
- {filter}
98
74
  {children}
99
-
100
- {footer !== null && footer !== undefined && (
101
- <box height={1} paddingLeft={1} paddingRight={1}>
102
- {footer}
103
- </box>
104
- )}
105
-
106
- <box
107
- flexDirection="row"
108
- height={1}
109
- paddingLeft={1}
110
- paddingRight={1}
111
- backgroundColor={theme.backgroundElement}
112
- >
113
- <box flexDirection="row" style={{ flexGrow: 1 }}>
114
- {typeof hintContent === "string" ? (
115
- <text content={hintContent} fg={theme.textMuted} />
116
- ) : (
117
- hintContent
118
- )}
119
- </box>
120
- {statusRight !== null &&
121
- statusRight !== undefined &&
122
- (typeof statusRight === "string" ? (
123
- <text content={statusRight} fg={theme.textMuted} />
124
- ) : (
125
- statusRight
126
- ))}
127
- </box>
128
- </box>
75
+ </OverlayPanel>
129
76
  );
130
77
  };
@@ -0,0 +1,146 @@
1
+ import { createStore } from "@xstate/store";
2
+ import { fuzzyFilter } from "./fuzzy.js";
3
+ import type { FuzzyMatch } from "./fuzzy.js";
4
+ import type { ChooseItem } from "./types.js";
5
+
6
+ export interface ChooseStoreContext {
7
+ items: ChooseItem[];
8
+ matches: FuzzyMatch[];
9
+ filterQuery: string;
10
+ activeIndex: number;
11
+ selectedOriginalIndices: ReadonlySet<number>;
12
+ loading: boolean;
13
+ error: string | null;
14
+ requestId: number;
15
+ reloadRevision: number;
16
+ }
17
+
18
+ interface ChooseStoreEventDefinitions {
19
+ filterChanged: { query: string };
20
+ activeIndexSet: { index: number };
21
+ moved: { delta: number };
22
+ activeToggled: Record<never, never>;
23
+ requestStarted: Record<never, never>;
24
+ loadingStarted: { requestId: number };
25
+ loadSucceeded: { requestId: number; items: ChooseItem[] };
26
+ loadFailed: { requestId: number; error: string };
27
+ reloadRequested: Record<never, never>;
28
+ }
29
+
30
+ export type ChooseStoreEvents = {
31
+ [EventName in keyof ChooseStoreEventDefinitions]: ChooseStoreEventDefinitions[EventName];
32
+ };
33
+
34
+ const replaceItems = function replaceItems(
35
+ ctx: ChooseStoreContext,
36
+ items: ChooseItem[],
37
+ ): ChooseStoreContext {
38
+ return {
39
+ ...ctx,
40
+ activeIndex: 0,
41
+ items,
42
+ matches: fuzzyFilter(items, ctx.filterQuery),
43
+ selectedOriginalIndices: new Set(),
44
+ };
45
+ };
46
+
47
+ const clampActiveIndex = function clampActiveIndex(index: number, matchCount: number): number {
48
+ return Math.min(Math.max(0, matchCount - 1), Math.max(0, index));
49
+ };
50
+
51
+ export const createChooseStore = function createChooseStore(options: {
52
+ items?: ChooseItem[];
53
+ initialFilter?: string;
54
+ loading?: boolean;
55
+ }) {
56
+ const items = options.items ?? [];
57
+ const filterQuery = options.initialFilter ?? "";
58
+
59
+ return createStore<ChooseStoreContext, ChooseStoreEvents>({
60
+ context: {
61
+ activeIndex: 0,
62
+ error: null,
63
+ filterQuery,
64
+ items,
65
+ loading: options.loading ?? false,
66
+ matches: fuzzyFilter(items, filterQuery),
67
+ reloadRevision: 0,
68
+ requestId: 0,
69
+ selectedOriginalIndices: new Set(),
70
+ },
71
+ on: {
72
+ activeIndexSet: (ctx, event) => {
73
+ const activeIndex = clampActiveIndex(event.index, ctx.matches.length);
74
+ return activeIndex === ctx.activeIndex ? ctx : { ...ctx, activeIndex };
75
+ },
76
+ activeToggled: (ctx) => {
77
+ const originalIndex = ctx.matches[ctx.activeIndex]?.originalIndex;
78
+ if (originalIndex === undefined) {
79
+ return ctx;
80
+ }
81
+ const selectedOriginalIndices = new Set(ctx.selectedOriginalIndices);
82
+ if (selectedOriginalIndices.has(originalIndex)) {
83
+ selectedOriginalIndices.delete(originalIndex);
84
+ } else {
85
+ selectedOriginalIndices.add(originalIndex);
86
+ }
87
+ return { ...ctx, selectedOriginalIndices };
88
+ },
89
+ filterChanged: (ctx, event) => {
90
+ if (event.query === ctx.filterQuery) {
91
+ return ctx;
92
+ }
93
+ return {
94
+ ...ctx,
95
+ activeIndex: 0,
96
+ filterQuery: event.query,
97
+ matches: fuzzyFilter(ctx.items, event.query),
98
+ };
99
+ },
100
+ loadFailed: (ctx, event) => {
101
+ if (event.requestId !== ctx.requestId) {
102
+ return ctx;
103
+ }
104
+ return {
105
+ ...replaceItems(ctx, []),
106
+ error: event.error,
107
+ loading: false,
108
+ };
109
+ },
110
+ loadSucceeded: (ctx, event) => {
111
+ if (event.requestId !== ctx.requestId) {
112
+ return ctx;
113
+ }
114
+ return {
115
+ ...replaceItems(ctx, event.items),
116
+ error: null,
117
+ loading: false,
118
+ };
119
+ },
120
+ loadingStarted: (ctx, event) =>
121
+ event.requestId === ctx.requestId && !ctx.loading ? { ...ctx, loading: true } : ctx,
122
+ moved: (ctx, event) => {
123
+ const activeIndex = clampActiveIndex(ctx.activeIndex + event.delta, ctx.matches.length);
124
+ return activeIndex === ctx.activeIndex ? ctx : { ...ctx, activeIndex };
125
+ },
126
+ reloadRequested: (ctx) => ({ ...ctx, reloadRevision: ctx.reloadRevision + 1 }),
127
+ requestStarted: (ctx) => ({
128
+ ...ctx,
129
+ error: null,
130
+ requestId: ctx.requestId + 1,
131
+ }),
132
+ },
133
+ });
134
+ };
135
+
136
+ export type ChooseStore = ReturnType<typeof createChooseStore>;
137
+
138
+ export const selectItems = (ctx: ChooseStoreContext): ChooseItem[] => ctx.items;
139
+ export const selectMatches = (ctx: ChooseStoreContext): FuzzyMatch[] => ctx.matches;
140
+ export const selectFilterQuery = (ctx: ChooseStoreContext): string => ctx.filterQuery;
141
+ export const selectActiveIndex = (ctx: ChooseStoreContext): number => ctx.activeIndex;
142
+ export const selectSelectedOriginalIndices = (ctx: ChooseStoreContext): ReadonlySet<number> =>
143
+ ctx.selectedOriginalIndices;
144
+ export const selectLoading = (ctx: ChooseStoreContext): boolean => ctx.loading;
145
+ export const selectError = (ctx: ChooseStoreContext): string | null => ctx.error;
146
+ export const selectReloadRevision = (ctx: ChooseStoreContext): number => ctx.reloadRevision;
package/src/choose.tsx CHANGED
@@ -1,10 +1,10 @@
1
1
  import { useImperativeHandle } from "react";
2
- import type { Ref } from "react";
2
+ import type { ReactNode, Ref } from "react";
3
3
  import { AppLayout } from "@tooee/layout";
4
4
  import { useHasOverlay, useHasModalOverlay } from "@tooee/overlays";
5
5
  import { useTheme } from "@tooee/themes";
6
6
  import { useThemeCommands, useQuitCommand } from "@tooee/shell";
7
- import { useCommandContext } from "@tooee/commands";
7
+ import { useSurfaceInvoke } from "@tooee/commands";
8
8
  import type { ActionDefinition } from "@tooee/commands";
9
9
  import { ChooseFilter } from "./choose-filter.js";
10
10
  import { ChooseList } from "./choose-list.js";
@@ -14,12 +14,12 @@ import type { ChooseContentProvider, ChooseOptions, ChooseResult } from "./types
14
14
  import { useChoose } from "./use-choose.js";
15
15
  import type { ChooseController } from "./use-choose.js";
16
16
 
17
- export interface ChooseProps {
17
+ export interface ChooseProps extends ChooseOptions {
18
18
  contentProvider: ChooseContentProvider;
19
+ /** @deprecated Pass chooser options as top-level props. This alias will be removed in 0.9.0. */
19
20
  options?: ChooseOptions;
20
- /** Legacy name retained for source compatibility. */
21
21
  actions?: ActionDefinition[];
22
- /** Additive alias used by new chooser compositions. */
22
+ /** @deprecated Use `actions`. This alias will be removed in 0.9.0. */
23
23
  commands?: ActionDefinition[];
24
24
  controllerRef?: Ref<ChooseController>;
25
25
  renderItem?: ChooseListProps["renderItem"];
@@ -36,20 +36,37 @@ export interface ChooseProps {
36
36
  onCancel?: () => void;
37
37
  }
38
38
 
39
- export const Choose = function Choose({
40
- contentProvider,
41
- options,
42
- actions,
43
- commands,
44
- controllerRef,
45
- renderItem,
46
- onConfirm,
47
- onCancel,
48
- }: ChooseProps): React.ReactNode {
39
+ interface ResolvedChooseProps extends ChooseOptions {
40
+ actions?: ActionDefinition[];
41
+ }
42
+
43
+ const resolveChooseProps = function resolveChooseProps(props: ChooseProps): ResolvedChooseProps {
44
+ // oxlint-disable-next-line typescript/no-deprecated -- compatibility alias remains supported until 0.9.0
45
+ const { options } = props;
46
+ // oxlint-disable-next-line typescript/no-deprecated -- compatibility alias remains supported until 0.9.0
47
+ const { commands } = props;
48
+ return {
49
+ actions: props.actions ?? commands,
50
+ emptyMessage: props.emptyMessage ?? options?.emptyMessage,
51
+ multi: props.multi ?? options?.multi,
52
+ placeholder: props.placeholder ?? options?.placeholder,
53
+ prompt: props.prompt ?? options?.prompt,
54
+ title: props.title ?? options?.title,
55
+ };
56
+ };
57
+
58
+ export const Choose = function Choose(props: ChooseProps): ReactNode {
59
+ const { contentProvider, controllerRef, renderItem, onConfirm, onCancel } = props;
60
+ const {
61
+ actions: effectiveActions,
62
+ emptyMessage,
63
+ multi = false,
64
+ placeholder,
65
+ prompt,
66
+ title,
67
+ } = resolveChooseProps(props);
49
68
  const { theme } = useTheme();
50
- const { invoke } = useCommandContext();
51
- const effectiveCommands = commands ?? actions;
52
- const multi = options?.multi ?? false;
69
+ const { invoke } = useSurfaceInvoke();
53
70
 
54
71
  const { name: themeName } = useThemeCommands();
55
72
  useQuitCommand({ onQuit: () => onCancel?.() });
@@ -58,13 +75,13 @@ export const Choose = function Choose({
58
75
  const hasModalOverlay = useHasModalOverlay();
59
76
 
60
77
  const choose = useChoose({
61
- commands: effectiveCommands,
78
+ commands: effectiveActions,
62
79
  multi,
63
80
  onCancel,
64
81
  onSubmit: (result) => {
65
82
  // Standalone behaviour: a command named `submit` wins over `onConfirm`, so
66
83
  // action-driven CLIs keep their own submit semantics.
67
- if (effectiveCommands?.some((action) => action.id === "submit") === true) {
84
+ if (effectiveActions?.some((action) => action.id === "submit") === true) {
68
85
  invoke("submit");
69
86
  return;
70
87
  }
@@ -98,10 +115,10 @@ export const Choose = function Choose({
98
115
 
99
116
  const hints = buildChooseHints(choose.view.mode, { multi });
100
117
  let titleBar: { title: string } | undefined;
101
- if (options?.title !== undefined && options.title !== "") {
102
- titleBar = { title: options.title };
103
- } else if (options?.prompt !== undefined && options.prompt !== "") {
104
- titleBar = { title: options.prompt };
118
+ if (title !== undefined && title !== "") {
119
+ titleBar = { title };
120
+ } else if (prompt !== undefined && prompt !== "") {
121
+ titleBar = { title: prompt };
105
122
  }
106
123
 
107
124
  return (
@@ -125,15 +142,14 @@ export const Choose = function Choose({
125
142
  { label: "", value: hints.join(" ") },
126
143
  ],
127
144
  }}
128
- scrollProps={{ focused: false }}
129
145
  >
130
146
  <box flexDirection="column" style={{ flexGrow: 1 }}>
131
- <ChooseFilter choose={choose} placeholder={options?.placeholder} />
147
+ <ChooseFilter choose={choose} placeholder={placeholder} />
132
148
  <ChooseList
133
149
  choose={choose}
134
150
  rowClick="activate"
135
151
  renderItem={renderItem}
136
- emptyContent={options?.emptyMessage}
152
+ emptyContent={emptyMessage}
137
153
  suspended={hasModalOverlay}
138
154
  />
139
155
  </box>
package/src/fuzzy.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { fuzzyMatchPositions } from "@tooee/fuzzy";
1
+ import { rankBy } from "@tooee/fuzzy";
2
2
  import type { ChooseItem } from "./types.js";
3
3
 
4
4
  export interface FuzzyMatch {
@@ -9,20 +9,5 @@ export interface FuzzyMatch {
9
9
  }
10
10
 
11
11
  export const fuzzyFilter = function fuzzyFilter(items: ChooseItem[], query: string): FuzzyMatch[] {
12
- if (!query) {
13
- return items.map((item, i) => ({ item, originalIndex: i, positions: [], score: 0 }));
14
- }
15
-
16
- const results: FuzzyMatch[] = [];
17
-
18
- for (let i = 0; i < items.length; i += 1) {
19
- const item = items[i];
20
- const match = fuzzyMatchPositions(query, item.text);
21
- if (match) {
22
- results.push({ item, originalIndex: i, positions: match.positions, score: match.score });
23
- }
24
- }
25
-
26
- results.sort((a, b) => b.score - a.score);
27
- return results;
12
+ return rankBy(items, query, (item) => item.text);
28
13
  };
package/src/launch.tsx CHANGED
@@ -2,6 +2,7 @@ import { runCliSession } from "@tooee/shell";
2
2
  import type { ActionDefinition } from "@tooee/commands";
3
3
  import { Choose } from "./choose.js";
4
4
  import type { ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js";
5
+ import type { ReactNode } from "react";
5
6
 
6
7
  export interface ChooseLaunchOptions {
7
8
  contentProvider: ChooseContentProvider;
@@ -13,10 +14,14 @@ export const launch = async function launch(
13
14
  opts: ChooseLaunchOptions,
14
15
  ): Promise<ChooseResult | null> {
15
16
  const result = await runCliSession<ChooseResult>(
16
- ({ resolve, cancel }): React.ReactNode => (
17
+ ({ resolve, cancel }): ReactNode => (
17
18
  <Choose
18
19
  contentProvider={opts.contentProvider}
19
- options={opts.options}
20
+ title={opts.options?.title}
21
+ prompt={opts.options?.prompt}
22
+ placeholder={opts.options?.placeholder}
23
+ multi={opts.options?.multi}
24
+ emptyMessage={opts.options?.emptyMessage}
20
25
  actions={opts.actions}
21
26
  onConfirm={resolve}
22
27
  onCancel={cancel}
package/src/source.ts CHANGED
@@ -1,18 +1,36 @@
1
- import type { ChooseItem, ChooseSource } from "./types.js";
1
+ import type { ChooseContentProvider, ChooseItem, ChooseSource } from "./types.js";
2
+
3
+ type DecodedChooseSource =
4
+ | { kind: "items"; items: ChooseItem[] }
5
+ | { kind: "loader"; load: () => ChooseItem[] | Promise<ChooseItem[]> }
6
+ | { kind: "provider"; provider: ChooseContentProvider };
7
+
8
+ const decodeChooseSource = function decodeChooseSource(source: ChooseSource): DecodedChooseSource {
9
+ if (Array.isArray(source)) {
10
+ return { items: source, kind: "items" };
11
+ }
12
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- public source boundary distinguishes the documented loader callback from a provider
13
+ if (typeof source === "function") {
14
+ return { kind: "loader", load: source };
15
+ }
16
+ return { kind: "provider", provider: source };
17
+ };
2
18
 
3
19
  /** Resolve every public Choose source shape through one compatibility boundary. */
4
20
  export const loadChooseSource = function loadChooseSource(
5
21
  source: ChooseSource,
6
22
  ): ChooseItem[] | Promise<ChooseItem[]> {
7
- if (Array.isArray(source)) {
8
- return source;
23
+ const decoded = decodeChooseSource(source);
24
+ if (decoded.kind === "items") {
25
+ return decoded.items;
9
26
  }
10
- if (typeof source === "function") {
11
- return source();
27
+ if (decoded.kind === "loader") {
28
+ return decoded.load();
12
29
  }
13
- return source.load();
30
+ return decoded.provider.load();
14
31
  };
15
32
 
33
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- caught loader failures enter here before conversion to display text
16
34
  export const chooseSourceError = function chooseSourceError(error: unknown): string {
17
35
  return error instanceof Error ? error.message : String(error);
18
36
  };