gloomberb 0.4.0 → 0.5.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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -0,0 +1,267 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { useDialog, useDialogKeyboard, type AlertContext } from "@opentui-ui/dialog/react";
4
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
5
+ import { colors } from "../../theme/colors";
6
+ import { ToggleList } from "../toggle-list";
7
+ import { Button } from "./button";
8
+ import { DialogFrame } from "./frame";
9
+ import {
10
+ moveMultiSelectValue,
11
+ normalizeMultiSelectValues,
12
+ summarizeMultiSelectValues,
13
+ toggleMultiSelectValue,
14
+ type MultiSelectOption,
15
+ } from "./multi-select";
16
+
17
+ export interface MultiSelectDialogContentProps extends AlertContext {
18
+ title: string;
19
+ options: MultiSelectOption[];
20
+ selectedValues: string[];
21
+ onChange: (values: string[]) => Promise<void> | void;
22
+ ordered?: boolean;
23
+ emptyLabel?: string;
24
+ idPrefix?: string;
25
+ }
26
+
27
+ export interface MultiSelectDialogButtonProps {
28
+ label: string;
29
+ title?: string;
30
+ options: MultiSelectOption[];
31
+ selectedValues: string[];
32
+ onChange: (values: string[]) => Promise<void> | void;
33
+ disabled?: boolean;
34
+ emptyLabel?: string;
35
+ ordered?: boolean;
36
+ idPrefix?: string;
37
+ renderTrigger?: (props: MultiSelectDialogTriggerProps) => ReactNode;
38
+ shortcutKey?: string | string[];
39
+ shortcutActive?: boolean;
40
+ }
41
+
42
+ type DialogTriggerEvent = { stopPropagation?: () => void; preventDefault?: () => void };
43
+
44
+ export interface MultiSelectDialogTriggerProps {
45
+ buttonLabel: string;
46
+ buttonText: string;
47
+ summary: string;
48
+ disabled: boolean;
49
+ openDialog: (event?: DialogTriggerEvent) => void;
50
+ stopMouseEvent: (event?: DialogTriggerEvent) => void;
51
+ }
52
+
53
+ function isSpaceKey(event: { name?: string; sequence?: string }): boolean {
54
+ return event.name === "space" || event.name === " " || event.sequence === " ";
55
+ }
56
+
57
+ function stopMouseEvent(event?: DialogTriggerEvent) {
58
+ event?.stopPropagation?.();
59
+ event?.preventDefault?.();
60
+ }
61
+
62
+ function matchesShortcut(event: { name?: string; sequence?: string }, shortcutKey: string | string[] | undefined): boolean {
63
+ if (!shortcutKey) return false;
64
+ const keys = Array.isArray(shortcutKey) ? shortcutKey : [shortcutKey];
65
+ const name = event.name?.toLowerCase() ?? "";
66
+ const sequence = event.sequence?.toLowerCase() ?? "";
67
+ return keys.some((key) => {
68
+ const normalized = key.toLowerCase();
69
+ return normalized === name || normalized === sequence;
70
+ });
71
+ }
72
+
73
+ export function MultiSelectDialogContent({
74
+ dismiss,
75
+ dialogId,
76
+ title,
77
+ options,
78
+ selectedValues: selectedValuesProp,
79
+ onChange,
80
+ ordered = false,
81
+ idPrefix,
82
+ }: MultiSelectDialogContentProps) {
83
+ const optionByValue = useMemo(() => new Map(options.map((option) => [option.value, option])), [options]);
84
+ const [selectedValues, setSelectedValues] = useState(() => normalizeMultiSelectValues(options, selectedValuesProp));
85
+ const knownSelectedValues = selectedValues.filter((value) => optionByValue.has(value));
86
+ const [selectedOptionId, setSelectedOptionId] = useState(options[0]?.value ?? "");
87
+ const selectedIndex = Math.max(0, options.findIndex((option) => option.value === selectedOptionId));
88
+ const selectedOption = options[selectedIndex];
89
+ const selectedOptionValue = selectedOption?.value ?? "";
90
+ const selectedValueOrder = knownSelectedValues.indexOf(selectedOptionValue);
91
+ const canMoveUp = ordered && selectedValueOrder > 0;
92
+ const canMoveDown = ordered
93
+ && selectedValueOrder >= 0
94
+ && selectedValueOrder < knownSelectedValues.length - 1;
95
+
96
+ useEffect(() => {
97
+ setSelectedValues(normalizeMultiSelectValues(options, selectedValuesProp));
98
+ }, [options, selectedValuesProp]);
99
+
100
+ useEffect(() => {
101
+ if (options.some((option) => option.value === selectedOptionId)) return;
102
+ setSelectedOptionId(options[0]?.value ?? "");
103
+ }, [options, selectedOptionId]);
104
+
105
+ const toggleItems = options.map((option) => {
106
+ const order = knownSelectedValues.indexOf(option.value);
107
+ const orderDescription = ordered && order >= 0
108
+ ? `Order ${order + 1} of ${knownSelectedValues.length}.`
109
+ : null;
110
+
111
+ return {
112
+ id: option.value,
113
+ label: option.label,
114
+ enabled: selectedValues.includes(option.value),
115
+ description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
116
+ };
117
+ });
118
+ const listHeight = Math.min(12, Math.max(6, toggleItems.length));
119
+
120
+ const applySelectedValues = async (nextValues: string[]) => {
121
+ const previousValues = selectedValues;
122
+ setSelectedValues(nextValues);
123
+ try {
124
+ await onChange(nextValues);
125
+ } catch (error) {
126
+ setSelectedValues(previousValues);
127
+ throw error;
128
+ }
129
+ };
130
+
131
+ const toggleOption = async (option: MultiSelectOption | undefined) => {
132
+ if (!option || option.disabled) return;
133
+ await applySelectedValues(toggleMultiSelectValue(options, selectedValues, option.value));
134
+ };
135
+
136
+ const moveOption = async (direction: "up" | "down") => {
137
+ if (!ordered || !selectedOption) return;
138
+ await applySelectedValues(moveMultiSelectValue(options, selectedValues, selectedOption.value, direction));
139
+ };
140
+
141
+ useDialogKeyboard((event) => {
142
+ event.stopPropagation();
143
+ if (event.name === "up" || event.name === "k") {
144
+ const nextIndex = Math.max(0, selectedIndex - 1);
145
+ setSelectedOptionId(options[nextIndex]?.value ?? selectedOptionId);
146
+ } else if (event.name === "down" || event.name === "j") {
147
+ const nextIndex = Math.min(options.length - 1, selectedIndex + 1);
148
+ setSelectedOptionId(options[nextIndex]?.value ?? selectedOptionId);
149
+ } else if (isSpaceKey(event)) {
150
+ void toggleOption(selectedOption).catch(() => {});
151
+ } else if (event.name === "[" && ordered) {
152
+ void moveOption("up").catch(() => {});
153
+ } else if (event.name === "]" && ordered) {
154
+ void moveOption("down").catch(() => {});
155
+ } else if (event.name === "enter" || event.name === "return" || event.name === "escape") {
156
+ dismiss();
157
+ }
158
+ }, dialogId);
159
+
160
+ return (
161
+ <DialogFrame title={title}>
162
+ <box flexDirection="column" gap={1}>
163
+ <ToggleList
164
+ items={toggleItems}
165
+ selectedIdx={selectedIndex}
166
+ bgColor={colors.commandBg}
167
+ height={listHeight}
168
+ scrollable
169
+ showSelectedDescription={false}
170
+ rowIdPrefix={idPrefix ? `${idPrefix}:option` : undefined}
171
+ onSelect={(index) => setSelectedOptionId(options[index]?.value ?? selectedOptionId)}
172
+ onToggle={(id) => {
173
+ setSelectedOptionId(id);
174
+ void toggleOption(optionByValue.get(id)).catch(() => {});
175
+ }}
176
+ />
177
+ <box flexDirection="row" gap={1}>
178
+ {ordered && (
179
+ <>
180
+ <Button label="Move Up" variant="ghost" disabled={!canMoveUp} onPress={() => { void moveOption("up").catch(() => {}); }} />
181
+ <Button label="Move Down" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
182
+ </>
183
+ )}
184
+ <Button label="Done" variant="primary" onPress={dismiss} />
185
+ </box>
186
+ </box>
187
+ </DialogFrame>
188
+ );
189
+ }
190
+
191
+ export function MultiSelectDialogButton({
192
+ label,
193
+ title,
194
+ options,
195
+ selectedValues,
196
+ onChange,
197
+ disabled = false,
198
+ emptyLabel = "None",
199
+ ordered = false,
200
+ idPrefix,
201
+ renderTrigger,
202
+ shortcutKey,
203
+ shortcutActive = false,
204
+ }: MultiSelectDialogButtonProps) {
205
+ const dialog = useDialog();
206
+ const summary = summarizeMultiSelectValues({ options, selectedValues, emptyLabel });
207
+ const buttonLabel = `${label}: ${summary}`;
208
+ const buttonText = ` ${buttonLabel} `;
209
+ const openDialog = (event?: DialogTriggerEvent) => {
210
+ stopMouseEvent(event);
211
+ if (disabled) return;
212
+ void dialog.alert({
213
+ closeOnClickOutside: true,
214
+ content: (ctx) => (
215
+ <MultiSelectDialogContent
216
+ {...ctx}
217
+ title={title ?? label}
218
+ options={options}
219
+ selectedValues={selectedValues}
220
+ onChange={onChange}
221
+ ordered={ordered}
222
+ emptyLabel={emptyLabel}
223
+ idPrefix={idPrefix}
224
+ />
225
+ ),
226
+ }).catch(() => {});
227
+ };
228
+
229
+ useKeyboard((event) => {
230
+ if (!shortcutActive || disabled || !matchesShortcut(event, shortcutKey)) return;
231
+ openDialog(event);
232
+ });
233
+
234
+ if (renderTrigger) {
235
+ return renderTrigger({
236
+ buttonLabel,
237
+ buttonText,
238
+ summary,
239
+ disabled,
240
+ openDialog,
241
+ stopMouseEvent,
242
+ });
243
+ }
244
+
245
+ return (
246
+ <box
247
+ id={idPrefix ? `${idPrefix}:button` : undefined}
248
+ height={1}
249
+ width={buttonText.length}
250
+ flexDirection="row"
251
+ backgroundColor={disabled ? colors.panel : colors.selected}
252
+ onMouseDown={stopMouseEvent}
253
+ onMouseUp={openDialog}
254
+ >
255
+ <text
256
+ fg={disabled ? colors.textMuted : colors.selectedText}
257
+ attributes={TextAttributes.BOLD}
258
+ onMouseDown={stopMouseEvent}
259
+ onMouseUp={openDialog}
260
+ >
261
+ {buttonText}
262
+ </text>
263
+ </box>
264
+ );
265
+ }
266
+
267
+ export type { MultiSelectOption };
@@ -0,0 +1,78 @@
1
+ export interface MultiSelectOption {
2
+ value: string;
3
+ label: string;
4
+ description?: string;
5
+ disabled?: boolean;
6
+ }
7
+
8
+ export function normalizeMultiSelectValues(
9
+ options: readonly MultiSelectOption[],
10
+ values: readonly string[],
11
+ ): string[] {
12
+ const knownValues = new Set(options.map((option) => option.value));
13
+ return [
14
+ ...options
15
+ .map((option) => option.value)
16
+ .filter((optionValue) => values.includes(optionValue)),
17
+ ...values.filter((optionValue) => !knownValues.has(optionValue)),
18
+ ];
19
+ }
20
+
21
+ export function toggleMultiSelectValue(
22
+ options: readonly MultiSelectOption[],
23
+ selectedValues: readonly string[],
24
+ value: string,
25
+ ): string[] {
26
+ const nextSelected = new Set(selectedValues);
27
+ if (nextSelected.has(value)) {
28
+ nextSelected.delete(value);
29
+ } else {
30
+ nextSelected.add(value);
31
+ }
32
+
33
+ return normalizeMultiSelectValues(options, [...nextSelected]);
34
+ }
35
+
36
+ export function moveMultiSelectValue(
37
+ options: readonly MultiSelectOption[],
38
+ selectedValues: readonly string[],
39
+ selectedOption: string,
40
+ direction: "up" | "down",
41
+ ): string[] {
42
+ if (!selectedValues.includes(selectedOption)) return [...selectedValues];
43
+
44
+ const optionValueSet = new Set(options.map((option) => option.value));
45
+ const ordered = selectedValues.filter((value) => optionValueSet.has(value));
46
+ const index = ordered.indexOf(selectedOption);
47
+ if (index < 0) return [...selectedValues];
48
+
49
+ const targetIndex = direction === "up"
50
+ ? Math.max(0, index - 1)
51
+ : Math.min(ordered.length - 1, index + 1);
52
+ if (targetIndex === index) return [...selectedValues];
53
+
54
+ const next = [...ordered];
55
+ const [entry] = next.splice(index, 1);
56
+ next.splice(targetIndex, 0, entry!);
57
+ const unknownValues = selectedValues.filter((value) => !optionValueSet.has(value));
58
+ return [...next, ...unknownValues];
59
+ }
60
+
61
+ export function summarizeMultiSelectValues({
62
+ options,
63
+ selectedValues,
64
+ emptyLabel = "None",
65
+ maxLabels = 2,
66
+ }: {
67
+ options: readonly MultiSelectOption[];
68
+ selectedValues: readonly string[];
69
+ emptyLabel?: string;
70
+ maxLabels?: number;
71
+ }): string {
72
+ const selectedLabels = selectedValues
73
+ .map((selectedValue) => options.find((option) => option.value === selectedValue)?.label ?? selectedValue);
74
+
75
+ if (selectedLabels.length === 0) return emptyLabel;
76
+ if (selectedLabels.length <= maxLabels) return selectedLabels.join(", ");
77
+ return `${selectedLabels.length} selected`;
78
+ }