gloomberb 0.4.1 → 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 (207) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  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 +292 -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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -3,15 +3,12 @@ import { useDialog, useDialogKeyboard, type AlertContext } from "@opentui-ui/dia
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import type {
5
5
  PaneSettingField,
6
- PaneSettingOption,
7
- PaneSettingOrderedMultiSelectField,
8
6
  PaneSettingTextField,
9
7
  } from "../types/plugin";
10
8
  import type { PluginRegistry } from "../plugins/registry";
11
9
  import { useAppState } from "../state/app-context";
12
10
  import { colors } from "../theme/colors";
13
- import { ToggleList } from "./toggle-list";
14
- import { Button, DialogFrame, ListView, TextField } from "./ui";
11
+ import { Button, DialogFrame, ListView, MultiSelectDialogContent, TextField } from "./ui";
15
12
 
16
13
  interface PaneSettingsDialogContentProps extends AlertContext {
17
14
  paneId: string;
@@ -56,37 +53,6 @@ function coerceSelectedValues(value: unknown): string[] {
56
53
  : [];
57
54
  }
58
55
 
59
- function toggleSelectedValue(currentValues: string[], value: string): string[] {
60
- return currentValues.includes(value)
61
- ? currentValues.filter((entry) => entry !== value)
62
- : [...currentValues, value];
63
- }
64
-
65
- function moveSelectedValue(
66
- field: PaneSettingOrderedMultiSelectField,
67
- currentValues: string[],
68
- selectedOption: string,
69
- direction: "up" | "down",
70
- ): string[] {
71
- if (!currentValues.includes(selectedOption)) return currentValues;
72
-
73
- const optionValueSet = new Set(field.options.map((option) => option.value));
74
- const ordered = currentValues.filter((value) => optionValueSet.has(value));
75
- const index = ordered.indexOf(selectedOption);
76
- if (index < 0) return currentValues;
77
-
78
- const targetIndex = direction === "up"
79
- ? Math.max(0, index - 1)
80
- : Math.min(ordered.length - 1, index + 1);
81
- if (targetIndex === index) return currentValues;
82
-
83
- const next = [...ordered];
84
- const [entry] = next.splice(index, 1);
85
- next.splice(targetIndex, 0, entry!);
86
- const unknownValues = currentValues.filter((value) => !optionValueSet.has(value));
87
- return [...next, ...unknownValues];
88
- }
89
-
90
56
  function SelectFieldDialog({
91
57
  dismiss,
92
58
  dialogId,
@@ -116,7 +82,7 @@ function SelectFieldDialog({
116
82
  return (
117
83
  <DialogFrame
118
84
  title={field.label}
119
- footer="Use ↑↓ to choose · enter to apply · esc to close"
85
+ footer="Use ↑↓ to choose · enter to apply · esc cancel"
120
86
  >
121
87
  <ListView
122
88
  items={field.options.map((option) => ({
@@ -175,7 +141,7 @@ function TextFieldDialog({
175
141
  return (
176
142
  <DialogFrame
177
143
  title={field.label}
178
- footer="Enter to apply · esc to close"
144
+ footer="Enter to apply · esc cancel"
179
145
  >
180
146
  <box flexDirection="column" gap={1}>
181
147
  <TextField
@@ -216,121 +182,16 @@ function MultiSelectFieldDialog({
216
182
  currentValue: unknown;
217
183
  onApply: (value: string[]) => Promise<void>;
218
184
  }) {
219
- const optionByValue = new Map(field.options.map((option) => [option.value, option]));
220
- const [selectedValues, setSelectedValues] = useState(() => coerceSelectedValues(currentValue));
221
- const knownSelectedValues = selectedValues.filter((value) => optionByValue.has(value));
222
- const orderedOptions = field.options;
223
- const [selectedOptionId, setSelectedOptionId] = useState(orderedOptions[0]?.value ?? "");
224
- const selectedIndex = Math.max(0, orderedOptions.findIndex((option) => option.value === selectedOptionId));
225
- const selectedOptionValue = orderedOptions[selectedIndex]?.value ?? "";
226
- const selectedValueOrder = knownSelectedValues.indexOf(selectedOptionValue);
227
- const canMoveUp = field.type === "ordered-multi-select" && selectedValueOrder > 0;
228
- const canMoveDown = field.type === "ordered-multi-select"
229
- && selectedValueOrder >= 0
230
- && selectedValueOrder < knownSelectedValues.length - 1;
231
-
232
- useEffect(() => {
233
- setSelectedValues(coerceSelectedValues(currentValue));
234
- }, [currentValue]);
235
-
236
- useEffect(() => {
237
- if (orderedOptions.some((option) => option.value === selectedOptionId)) return;
238
- setSelectedOptionId(orderedOptions[0]?.value ?? "");
239
- }, [orderedOptions, selectedOptionId]);
240
-
241
- const toggleItems = orderedOptions.map((option) => {
242
- const order = knownSelectedValues.indexOf(option.value);
243
- const orderDescription = field.type === "ordered-multi-select" && order >= 0
244
- ? `Order ${order + 1} of ${knownSelectedValues.length}.`
245
- : null;
246
-
247
- return {
248
- id: option.value,
249
- label: option.label,
250
- enabled: selectedValues.includes(option.value),
251
- description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
252
- };
253
- });
254
- const listHeight = Math.min(12, Math.max(6, toggleItems.length));
255
-
256
- const applySelectedValues = async (nextValues: string[]) => {
257
- const previousValues = selectedValues;
258
- setSelectedValues(nextValues);
259
- try {
260
- await onApply(nextValues);
261
- } catch (error) {
262
- setSelectedValues(previousValues);
263
- throw error;
264
- }
265
- };
266
-
267
- const toggleOption = async (option: PaneSettingOption | undefined) => {
268
- if (!option) return;
269
- await applySelectedValues(toggleSelectedValue(selectedValues, option.value));
270
- };
271
-
272
- const moveOption = async (direction: "up" | "down") => {
273
- if (field.type !== "ordered-multi-select") return;
274
- const option = orderedOptions[selectedIndex];
275
- if (!option) return;
276
- await applySelectedValues(moveSelectedValue(field, selectedValues, option.value, direction));
277
- };
278
-
279
- useDialogKeyboard((event) => {
280
- event.stopPropagation();
281
- if (event.name === "up" || event.name === "k") {
282
- const nextIndex = Math.max(0, selectedIndex - 1);
283
- setSelectedOptionId(orderedOptions[nextIndex]?.value ?? selectedOptionId);
284
- } else if (event.name === "down" || event.name === "j") {
285
- const nextIndex = Math.min(orderedOptions.length - 1, selectedIndex + 1);
286
- setSelectedOptionId(orderedOptions[nextIndex]?.value ?? selectedOptionId);
287
- }
288
- else if (isSpaceKey(event)) {
289
- void toggleOption(orderedOptions[selectedIndex]).catch(() => {});
290
- } else if (event.name === "[" && field.type === "ordered-multi-select") {
291
- void moveOption("up").catch(() => {});
292
- } else if (event.name === "]" && field.type === "ordered-multi-select") {
293
- void moveOption("down").catch(() => {});
294
- } else if (event.name === "enter" || event.name === "return") {
295
- dismiss();
296
- } else if (event.name === "escape") {
297
- dismiss();
298
- }
299
- }, dialogId);
300
-
301
185
  return (
302
- <DialogFrame
186
+ <MultiSelectDialogContent
187
+ dismiss={dismiss}
188
+ dialogId={dialogId}
303
189
  title={field.label}
304
- footer={field.type === "ordered-multi-select"
305
- ? "space toggle · [ ] reorder · enter done"
306
- : "space toggle · enter done"}
307
- >
308
- <box flexDirection="column" gap={1}>
309
- <ToggleList
310
- items={toggleItems}
311
- selectedIdx={selectedIndex}
312
- bgColor={colors.commandBg}
313
- height={listHeight}
314
- scrollable
315
- showSelectedDescription={false}
316
- onSelect={(index) => setSelectedOptionId(orderedOptions[index]?.value ?? selectedOptionId)}
317
- onToggle={(id) => {
318
- setSelectedOptionId(id);
319
- void toggleOption(optionByValue.get(id)).catch(() => {});
320
- }}
321
- />
322
- <box flexDirection="row" gap={1}>
323
- <Button label="Toggle" variant="secondary" onPress={() => { void toggleOption(orderedOptions[selectedIndex]).catch(() => {}); }} />
324
- {field.type === "ordered-multi-select" && (
325
- <>
326
- <Button label="Move Up" variant="ghost" disabled={!canMoveUp} onPress={() => { void moveOption("up").catch(() => {}); }} />
327
- <Button label="Move Down" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
328
- </>
329
- )}
330
- <Button label="Done" variant="primary" onPress={dismiss} />
331
- </box>
332
- </box>
333
- </DialogFrame>
190
+ options={field.options}
191
+ selectedValues={coerceSelectedValues(currentValue)}
192
+ onChange={onApply}
193
+ ordered={field.type === "ordered-multi-select"}
194
+ />
334
195
  );
335
196
  }
336
197
 
@@ -415,7 +276,7 @@ export function PaneSettingsDialogContent({
415
276
 
416
277
  if (!descriptor) {
417
278
  return (
418
- <DialogFrame title="Pane Settings" footer="Press esc to close">
279
+ <DialogFrame title="Pane Settings" footer="Press esc to cancel">
419
280
  <text fg={colors.textDim}>This pane is no longer configurable.</text>
420
281
  </DialogFrame>
421
282
  );
@@ -424,7 +285,7 @@ export function PaneSettingsDialogContent({
424
285
  const title = descriptor.settingsDef.title ?? `${descriptor.paneDef.name} Settings`;
425
286
 
426
287
  return (
427
- <DialogFrame title={title} footer="Use ↑↓ to choose · enter to edit · esc to close">
288
+ <DialogFrame title={title} footer="Use ↑↓ to choose · enter to edit · esc cancel">
428
289
  <ListView
429
290
  items={fields.map((field) => ({
430
291
  id: field.key,
@@ -126,6 +126,7 @@ export function PaneTemplateTextareaStep({
126
126
  placeholderColor={colors.textDim}
127
127
  backgroundColor={colors.panel}
128
128
  flexGrow={1}
129
+ wrapText
129
130
  />
130
131
  </box>
131
132
  <box height={1} />
@@ -4,6 +4,7 @@ import type { InputRenderable } from "@opentui/core";
4
4
  import { useDialogKeyboard, type PromptContext } from "@opentui-ui/dialog/react";
5
5
  import type { Quote } from "../types/financials";
6
6
  import { colors } from "../theme/colors";
7
+ import { formatMarketPrice } from "../utils/market-format";
7
8
  import { DialogFrame, ListView, NumberField } from "./ui";
8
9
  import { padTo } from "../utils/format";
9
10
 
@@ -17,6 +18,7 @@ export interface PriceSelectorDialogProps {
17
18
  label: string;
18
19
  currentValue?: number;
19
20
  quote?: Quote;
21
+ assetCategory?: string;
20
22
  }
21
23
 
22
24
  function buildPresets(quote: Quote | undefined): PricePreset[] {
@@ -46,20 +48,22 @@ function buildPresets(quote: Quote | undefined): PricePreset[] {
46
48
  return presets;
47
49
  }
48
50
 
49
- function formatMarketContext(quote: Quote | undefined): string {
51
+ function formatMarketContext(quote: Quote | undefined, assetCategory?: string): string {
50
52
  if (!quote) return "No quote data available";
51
53
  const parts: string[] = [];
52
- parts.push(`Last ${quote.price.toFixed(2)}`);
54
+ parts.push(`Last ${formatMarketPrice(quote.price, { assetCategory })}`);
53
55
  if (quote.bid != null) {
54
- const bidStr = quote.bidSize != null ? `Bid ${quote.bid.toFixed(2)} × ${quote.bidSize}` : `Bid ${quote.bid.toFixed(2)}`;
56
+ const bidValue = formatMarketPrice(quote.bid, { assetCategory });
57
+ const bidStr = quote.bidSize != null ? `Bid ${bidValue} × ${quote.bidSize}` : `Bid ${bidValue}`;
55
58
  parts.push(bidStr);
56
59
  }
57
60
  if (quote.ask != null) {
58
- const askStr = quote.askSize != null ? `Ask ${quote.ask.toFixed(2)} × ${quote.askSize}` : `Ask ${quote.ask.toFixed(2)}`;
61
+ const askValue = formatMarketPrice(quote.ask, { assetCategory });
62
+ const askStr = quote.askSize != null ? `Ask ${askValue} × ${quote.askSize}` : `Ask ${askValue}`;
59
63
  parts.push(askStr);
60
64
  }
61
65
  if (quote.bid != null && quote.ask != null) {
62
- parts.push(`Spd ${(quote.ask - quote.bid).toFixed(2)}`);
66
+ parts.push(`Spd ${formatMarketPrice(quote.ask - quote.bid, { assetCategory })}`);
63
67
  }
64
68
  return parts.join(" · ");
65
69
  }
@@ -72,6 +76,7 @@ export function PriceSelectorDialog({
72
76
  label,
73
77
  currentValue,
74
78
  quote,
79
+ assetCategory,
75
80
  }: PromptContext<string> & PriceSelectorDialogProps) {
76
81
  const presets = buildPresets(quote);
77
82
  const [index, setIndex] = useState(0);
@@ -135,7 +140,7 @@ export function PriceSelectorDialog({
135
140
  }
136
141
  >
137
142
  <box flexDirection="column">
138
- <text fg={colors.textDim}>{formatMarketContext(quote)}</text>
143
+ <text fg={colors.textDim}>{formatMarketContext(quote, assetCategory)}</text>
139
144
  <box height={1} />
140
145
 
141
146
  {presets.length > 0 && (
@@ -163,7 +168,7 @@ export function PriceSelectorDialog({
163
168
  {padTo(item.label, presetWidth)}
164
169
  </text>
165
170
  <text fg={state.selected ? colors.text : colors.textMuted}>
166
- {preset?.value.toFixed(2) ?? ""}
171
+ {preset ? formatMarketPrice(preset.value, { assetCategory }) : ""}
167
172
  </text>
168
173
  {preset?.detail && (
169
174
  <text fg={colors.textDim}>{` ${preset.detail}`}</text>
@@ -96,6 +96,56 @@ describe("TickerBadgeText", () => {
96
96
  expect(opened).toEqual(["TSLA"]);
97
97
  });
98
98
 
99
+ test("renders inline links alongside ticker badges", async () => {
100
+ testSetup = await testRender(
101
+ <TickerBadgeText
102
+ text="Read https://example.com while watching $TSLA"
103
+ lineWidth={60}
104
+ catalog={{ TSLA: makeCatalogEntry() }}
105
+ textColor="#ffffff"
106
+ openTicker={() => {}}
107
+ />,
108
+ { width: 60, height: 4 },
109
+ );
110
+
111
+ await testSetup.renderOnce();
112
+
113
+ const frame = testSetup.captureCharFrame();
114
+ expect(frame).toContain("https://example.com");
115
+ expect(frame).toContain("TSLA -5%");
116
+ });
117
+
118
+ test("opens detected links without trailing punctuation when clicked", async () => {
119
+ const opened: string[] = [];
120
+ testSetup = await testRender(
121
+ <TickerBadgeText
122
+ text="Read https://example.com/story."
123
+ lineWidth={60}
124
+ catalog={{}}
125
+ textColor="#ffffff"
126
+ openTicker={() => {}}
127
+ openLink={(url) => opened.push(url)}
128
+ />,
129
+ { width: 60, height: 4 },
130
+ );
131
+
132
+ await testSetup.renderOnce();
133
+
134
+ const lines = testSetup.captureCharFrame().split("\n");
135
+ const row = lines.findIndex((line) => line.includes("https://example.com/story."));
136
+ const col = lines[row]?.indexOf("https://example.com/story") ?? -1;
137
+
138
+ expect(row).toBeGreaterThanOrEqual(0);
139
+ expect(col).toBeGreaterThanOrEqual(0);
140
+
141
+ await act(async () => {
142
+ await testSetup!.mockMouse.click(col + 1, row);
143
+ await testSetup!.renderOnce();
144
+ });
145
+
146
+ expect(opened).toEqual(["https://example.com/story"]);
147
+ });
148
+
99
149
  test("shows the current price when the badge is hovered", async () => {
100
150
  testSetup = await testRender(
101
151
  <TickerBadgeText
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { TickerBadge } from "./ticker-badge";
3
- import { tokenizeTickerText } from "../utils/ticker-tokenizer";
3
+ import { ExternalLinkText, openUrl } from "./ui";
4
+ import { tokenizeInlineContent } from "../utils/inline-content-tokenizer";
4
5
  import type { InlineTickerCatalogEntry } from "../state/use-inline-tickers";
5
6
 
6
7
  export interface TickerBadgeTextProps {
@@ -9,6 +10,7 @@ export interface TickerBadgeTextProps {
9
10
  catalog: Record<string, InlineTickerCatalogEntry>;
10
11
  textColor: string;
11
12
  openTicker: (symbol: string) => void;
13
+ openLink?: (url: string) => void;
12
14
  }
13
15
 
14
16
  export function TickerBadgeText({
@@ -17,9 +19,10 @@ export function TickerBadgeText({
17
19
  catalog,
18
20
  textColor,
19
21
  openTicker,
22
+ openLink = openUrl,
20
23
  }: TickerBadgeTextProps) {
21
24
  const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
22
- const tokens = tokenizeTickerText(text);
25
+ const tokens = tokenizeInlineContent(text);
23
26
 
24
27
  return (
25
28
  <box
@@ -33,6 +36,18 @@ export function TickerBadgeText({
33
36
  return <text key={`text:${index}`} fg={textColor}>{token.value}</text>;
34
37
  }
35
38
 
39
+ if (token.kind === "link") {
40
+ return (
41
+ <ExternalLinkText
42
+ key={`link:${index}`}
43
+ url={token.url}
44
+ label={token.value}
45
+ color={textColor}
46
+ onOpen={openLink}
47
+ />
48
+ );
49
+ }
50
+
36
51
  const entry = catalog[token.symbol];
37
52
  if (!entry || entry.status === "missing") {
38
53
  return <text key={`raw:${index}`} fg={textColor}>{token.value}</text>;
@@ -1,6 +1,6 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
2
  import { colors, priceColor } from "../theme/colors";
3
- import { formatCurrency } from "../utils/format";
3
+ import { formatMarketPriceWithCurrency } from "../utils/market-format";
4
4
  import type { Quote } from "../types/financials";
5
5
 
6
6
  export interface TickerBadgeProps {
@@ -50,7 +50,7 @@ export function TickerBadge({
50
50
  }: TickerBadgeProps) {
51
51
  const tone = status === "ready" && quote ? priceColor(quote.changePercent) : colors.borderFocused;
52
52
  const label = hovered && quote
53
- ? `${symbol} ${formatCurrency(quote.price, quote.currency)}`
53
+ ? `${symbol} ${formatMarketPriceWithCurrency(quote.price, quote.currency, { minimumFractionDigits: 2 })}`
54
54
  : status === "ready" && quote
55
55
  ? formatBadgeChange(quote.changePercent)
56
56
  : "…";
@@ -3,9 +3,11 @@ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { EmptyState } from "./ui";
4
4
  import { colors, hoverBg } from "../theme/colors";
5
5
  import type { ColumnConfig } from "../types/config";
6
- import type { TickerFinancials } from "../types/financials";
6
+ import type { TickerFinancials, PricePoint } from "../types/financials";
7
7
  import type { TickerRecord } from "../types/ticker";
8
8
  import { padTo } from "../utils/format";
9
+ import { renderChart, resolveChartPalette, type StyledContent } from "./chart/chart-renderer";
10
+ import type { ProjectedChartPoint } from "./chart/chart-data";
9
11
 
10
12
  export interface TickerTableCell {
11
13
  text: string;
@@ -64,6 +66,7 @@ interface TickerListTableProps {
64
66
  onRowActivate?: (ticker: TickerRecord) => void;
65
67
  emptyTitle?: string;
66
68
  emptyHint?: string;
69
+ showSparklines?: boolean;
67
70
  }
68
71
 
69
72
  const TickerListHeader = memo(function TickerListHeader({
@@ -118,6 +121,38 @@ const TickerListHeader = memo(function TickerListHeader({
118
121
  );
119
122
  });
120
123
 
124
+ function renderSparkline(priceHistory: PricePoint[]): StyledContent | null {
125
+ if (priceHistory.length < 5) return null;
126
+
127
+ const last20 = priceHistory.slice(-20);
128
+ const points: ProjectedChartPoint[] = last20.map((pt) => ({
129
+ date: pt.date,
130
+ open: pt.open ?? pt.close,
131
+ high: pt.high ?? pt.close,
132
+ low: pt.low ?? pt.close,
133
+ close: pt.close,
134
+ volume: pt.volume ?? 0,
135
+ }));
136
+
137
+ const first = last20[0]?.close ?? 0;
138
+ const last = last20[last20.length - 1]?.close ?? 0;
139
+ const trend = last >= first ? "positive" : "negative";
140
+ const palette = resolveChartPalette(colors, trend);
141
+
142
+ const result = renderChart(points, {
143
+ width: 10,
144
+ height: 1,
145
+ showVolume: false,
146
+ volumeHeight: 0,
147
+ cursorX: null,
148
+ cursorY: null,
149
+ mode: "line",
150
+ colors: palette,
151
+ });
152
+
153
+ return result.lines[0] ?? null;
154
+ }
155
+
121
156
  const TickerListRow = memo(function TickerListRow({
122
157
  columns,
123
158
  ticker,
@@ -130,6 +165,8 @@ const TickerListRow = memo(function TickerListRow({
130
165
  setCursorSymbol,
131
166
  resolveCell,
132
167
  onRowActivate,
168
+ showSparklines,
169
+ priceHistory,
133
170
  }: {
134
171
  columns: ColumnConfig[];
135
172
  ticker: TickerRecord;
@@ -142,10 +179,16 @@ const TickerListRow = memo(function TickerListRow({
142
179
  setCursorSymbol: (symbol: string) => void;
143
180
  resolveCell: ResolveTickerTableCell;
144
181
  onRowActivate?: (ticker: TickerRecord) => void;
182
+ showSparklines?: boolean;
183
+ priceHistory?: PricePoint[];
145
184
  }) {
146
185
  const lastActivatedAtRef = useRef<number | null>(null);
147
186
  const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
148
187
 
188
+ const sparkline = showSparklines && priceHistory && priceHistory.length >= 5
189
+ ? renderSparkline(priceHistory)
190
+ : null;
191
+
149
192
  return (
150
193
  <box
151
194
  flexDirection="row"
@@ -182,6 +225,11 @@ const TickerListRow = memo(function TickerListRow({
182
225
  </box>
183
226
  );
184
227
  })}
228
+ {sparkline && (
229
+ <box width={12}>
230
+ <text content={sparkline} />
231
+ </box>
232
+ )}
185
233
  </box>
186
234
  );
187
235
  }, (previous, next) => (
@@ -196,6 +244,8 @@ const TickerListRow = memo(function TickerListRow({
196
244
  && previous.setCursorSymbol === next.setCursorSymbol
197
245
  && previous.resolveCell === next.resolveCell
198
246
  && previous.onRowActivate === next.onRowActivate
247
+ && previous.showSparklines === next.showSparklines
248
+ && previous.priceHistory === next.priceHistory
199
249
  ));
200
250
 
201
251
  export function TickerListTable({
@@ -218,6 +268,7 @@ export function TickerListTable({
218
268
  onRowActivate,
219
269
  emptyTitle = "No tickers.",
220
270
  emptyHint = "Press Ctrl+P to add one.",
271
+ showSparklines,
221
272
  }: TickerListTableProps) {
222
273
  const safeFlashSymbols = flashSymbols ?? new Map<string, QuoteFlashDirection>();
223
274
 
@@ -262,6 +313,8 @@ export function TickerListTable({
262
313
  setCursorSymbol={setCursorSymbol}
263
314
  resolveCell={resolveCell}
264
315
  onRowActivate={onRowActivate}
316
+ showSparklines={showSparklines}
317
+ priceHistory={financialsMap.get(ticker.metadata.ticker)?.priceHistory}
265
318
  />
266
319
  );
267
320
  })
@@ -20,6 +20,7 @@ export interface ToggleListProps {
20
20
  flexGrow?: number;
21
21
  scrollable?: boolean;
22
22
  showSelectedDescription?: boolean;
23
+ rowIdPrefix?: string;
23
24
  }
24
25
 
25
26
  export function ToggleList({
@@ -32,6 +33,7 @@ export function ToggleList({
32
33
  flexGrow,
33
34
  scrollable,
34
35
  showSelectedDescription = true,
36
+ rowIdPrefix,
35
37
  }: ToggleListProps) {
36
38
  const listItems: ListViewItem[] = items.map((item) => ({
37
39
  id: item.id,
@@ -52,17 +54,28 @@ export function ToggleList({
52
54
  onActivate={(item) => {
53
55
  onToggle?.(item.id);
54
56
  }}
55
- renderRow={(item, state) => {
57
+ renderRow={(item, state, index) => {
56
58
  const toggleItem = items.find((entry) => entry.id === item.id);
57
59
  const checked = toggleItem?.enabled ? "\u2713" : " ";
60
+ const activate = (event: any) => {
61
+ event.stopPropagation?.();
62
+ if (state.disabled) return;
63
+ onSelect?.(index);
64
+ onToggle?.(item.id);
65
+ };
58
66
  return (
59
- <box flexDirection="row">
67
+ <box
68
+ id={rowIdPrefix ? `${rowIdPrefix}:${item.id}` : undefined}
69
+ flexDirection="row"
70
+ onMouseDown={activate}
71
+ >
60
72
  <text fg={state.selected ? colors.selectedText : colors.textDim}>
61
73
  {state.selected ? "\u25b8 " : " "}
62
74
  </text>
63
75
  <text
64
76
  fg={state.selected ? colors.text : colors.textDim}
65
77
  attributes={state.selected ? TextAttributes.BOLD : 0}
78
+ onMouseDown={activate}
66
79
  >
67
80
  {`[${checked}] ${item.label}`}
68
81
  </text>