gloomberb 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) 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/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  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 +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  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 +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  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 +24 -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/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -19,6 +19,7 @@ import {
19
19
  resolveFloating,
20
20
  simulateDrop,
21
21
  swapPanes,
22
+ type DockDividerLayout,
22
23
  type DockLeafLayout,
23
24
  type DropTarget,
24
25
  type FloatingRect,
@@ -38,7 +39,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
38
39
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
39
40
  import { FloatingPaneWrapper } from "./floating-pane";
40
41
  import { PaneWrapper } from "./pane";
41
- import { getPaneBodyHeight } from "./pane-sizing";
42
+ import { getPaneBodyHeight, getPaneBodyWidth } from "./pane-sizing";
42
43
 
43
44
  interface ShellProps {
44
45
  pluginRegistry: PluginRegistry;
@@ -778,8 +779,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
778
779
  event.preventDefault();
779
780
  return;
780
781
  }
781
- event.stopPropagation();
782
- event.preventDefault();
782
+ // Focus floating panes without consuming body clicks so child controls
783
+ // can act on the same initial mouse interaction.
783
784
  return;
784
785
  }
785
786
 
@@ -837,8 +838,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
837
838
  event.preventDefault();
838
839
  return;
839
840
  }
840
- event.stopPropagation();
841
- event.preventDefault();
841
+ // Focus docked panes without consuming body clicks so embedded widgets
842
+ // receive the first click instead of requiring a second one.
842
843
  return;
843
844
  }
844
845
 
@@ -986,6 +987,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
986
987
  const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
987
988
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
988
989
  const bodyHeight = getPaneBodyHeight(leaf.rect.height);
990
+ const bodyWidth = getPaneBodyWidth(leaf.rect.width, focused);
989
991
  return (
990
992
  <box
991
993
  key={`dock:${leaf.instanceId}`}
@@ -1008,7 +1010,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1008
1010
  paneId={pane.instance.instanceId}
1009
1011
  paneType={pane.instance.paneId}
1010
1012
  focused={focused}
1011
- width={leaf.rect.width}
1013
+ width={bodyWidth}
1012
1014
  height={bodyHeight}
1013
1015
  />
1014
1016
  </PaneInstanceProvider>
@@ -1022,6 +1024,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1022
1024
  const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1023
1025
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1024
1026
  const bodyHeight = getPaneBodyHeight(preview.height);
1027
+ const bodyWidth = getPaneBodyWidth(preview.width, focused);
1025
1028
  return (
1026
1029
  <FloatingPaneWrapper
1027
1030
  key={`float:${pane.instance.instanceId}`}
@@ -1040,7 +1043,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1040
1043
  paneId={pane.instance.instanceId}
1041
1044
  paneType={pane.instance.paneId}
1042
1045
  focused={focused}
1043
- width={preview.width}
1046
+ width={bodyWidth}
1044
1047
  height={bodyHeight}
1045
1048
  close={() => handleFloatingClose(pane.instance.instanceId)}
1046
1049
  />
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { testRender } from "@opentui/react/test-utils";
3
3
  import { AppContext, createInitialState } from "../../state/app-context";
4
- import { cloneLayout, createDefaultConfig } from "../../types/config";
4
+ import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../types/config";
5
+ import type { AppNotificationRequest } from "../../types/plugin";
5
6
  import { StatusBar } from "./status-bar";
6
7
  import { setSharedRegistryForTests } from "../../plugins/registry";
7
8
 
@@ -89,13 +90,13 @@ describe("StatusBar", () => {
89
90
 
90
91
  const actions: Array<{ type: string }> = [];
91
92
  let updatedLayout = null as ReturnType<typeof cloneLayout> | null;
92
- const toasts: string[] = [];
93
+ const notifications: AppNotificationRequest[] = [];
93
94
 
94
95
  setSharedRegistryForTests({
95
96
  getLayoutFn: () => state.config.layout,
96
97
  getTermSizeFn: () => ({ width: 120, height: 40 }),
97
- updateLayoutFn: (layout) => { updatedLayout = layout; },
98
- showToastFn: (message: string) => { toasts.push(message); },
98
+ updateLayoutFn: (layout: LayoutConfig) => { updatedLayout = layout; },
99
+ notify: (notification: AppNotificationRequest) => { notifications.push(notification); },
99
100
  Slot: () => null,
100
101
  } as any);
101
102
 
@@ -119,8 +120,16 @@ describe("StatusBar", () => {
119
120
  await testSetup.renderOnce();
120
121
 
121
122
  expect(updatedLayout?.floating).toHaveLength(0);
122
- expect(toasts).toEqual(["Retiled all panes"]);
123
+ expect(notifications).toHaveLength(1);
124
+ expect(notifications[0]).toMatchObject({
125
+ body: "Retiled all panes",
126
+ type: "success",
127
+ action: { label: "Revert" },
128
+ });
123
129
  expect(actions).toContainEqual({ type: "DISMISS_GRIDLOCK_TIP" });
130
+
131
+ notifications[0]!.action!.onClick();
132
+ expect(actions).toContainEqual({ type: "UNDO_LAYOUT" });
124
133
  });
125
134
 
126
135
  test("auto-dismisses the gridlock tip after its timeout", async () => {
@@ -136,7 +145,7 @@ describe("StatusBar", () => {
136
145
  const originalSetTimeout = globalThis.setTimeout;
137
146
  const originalClearTimeout = globalThis.clearTimeout;
138
147
 
139
- globalThis.setTimeout = ((callback: TimerHandler, delay?: number) => {
148
+ globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number) => {
140
149
  timers.push({ callback: typeof callback === "function" ? callback : null, delay });
141
150
  return 1 as unknown as ReturnType<typeof setTimeout>;
142
151
  }) as typeof setTimeout;
@@ -146,7 +155,7 @@ describe("StatusBar", () => {
146
155
  getLayoutFn: () => state.config.layout,
147
156
  getTermSizeFn: () => ({ width: 120, height: 40 }),
148
157
  updateLayoutFn: () => {},
149
- showToastFn: () => {},
158
+ notify: () => {},
150
159
  Slot: () => null,
151
160
  } as any);
152
161
 
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../utils/market-status";
11
11
  import { getSharedRegistry } from "../../plugins/registry";
12
12
  import { gridlockAllPanes } from "../../plugins/pane-manager";
13
+ import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
13
14
 
14
15
  const GRIDLOCK_TIP_DURATION_MS = 60_000;
15
16
 
@@ -60,7 +61,9 @@ export function StatusBar() {
60
61
  if (!registry) return;
61
62
  const { width, height } = registry.getTermSizeFn();
62
63
  registry.updateLayoutFn(gridlockAllPanes(registry.getLayoutFn(), { x: 0, y: 0, width, height }));
63
- registry.showToastFn("Retiled all panes", { type: "success" });
64
+ notifyGridlockComplete(registry.notify.bind(registry), () => {
65
+ dispatch({ type: "UNDO_LAYOUT" });
66
+ });
64
67
  dispatch({ type: "DISMISS_GRIDLOCK_TIP" });
65
68
  };
66
69
 
@@ -129,6 +129,7 @@ export function MarkdownEditor({
129
129
  placeholderColor={colors.textDim}
130
130
  backgroundColor={focused ? colors.panel : colors.bg}
131
131
  flexGrow={1}
132
+ wrapText
132
133
  />
133
134
  );
134
135
  }
@@ -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
  : "…";