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
@@ -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,6 +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";
42
+ import { getPaneBodyHeight } from "./pane-sizing";
41
43
 
42
44
  interface ShellProps {
43
45
  pluginRegistry: PluginRegistry;
@@ -777,8 +779,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
777
779
  event.preventDefault();
778
780
  return;
779
781
  }
780
- event.stopPropagation();
781
- event.preventDefault();
782
+ // Focus floating panes without consuming body clicks so child controls
783
+ // can act on the same initial mouse interaction.
782
784
  return;
783
785
  }
784
786
 
@@ -836,8 +838,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
836
838
  event.preventDefault();
837
839
  return;
838
840
  }
839
- event.stopPropagation();
840
- event.preventDefault();
841
+ // Focus docked panes without consuming body clicks so embedded widgets
842
+ // receive the first click instead of requiring a second one.
841
843
  return;
842
844
  }
843
845
 
@@ -984,6 +986,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
984
986
  if (!pane) return null;
985
987
  const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
986
988
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
989
+ const bodyHeight = getPaneBodyHeight(leaf.rect.height);
987
990
  return (
988
991
  <box
989
992
  key={`dock:${leaf.instanceId}`}
@@ -1007,7 +1010,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1007
1010
  paneType={pane.instance.paneId}
1008
1011
  focused={focused}
1009
1012
  width={leaf.rect.width}
1010
- height={leaf.rect.height - 1}
1013
+ height={bodyHeight}
1011
1014
  />
1012
1015
  </PaneInstanceProvider>
1013
1016
  </PaneWrapper>
@@ -1019,6 +1022,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1019
1022
  const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1020
1023
  const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1021
1024
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1025
+ const bodyHeight = getPaneBodyHeight(preview.height);
1022
1026
  return (
1023
1027
  <FloatingPaneWrapper
1024
1028
  key={`float:${pane.instance.instanceId}`}
@@ -1038,7 +1042,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1038
1042
  paneType={pane.instance.paneId}
1039
1043
  focused={focused}
1040
1044
  width={preview.width}
1041
- height={preview.height - 1}
1045
+ height={bodyHeight}
1042
1046
  close={() => handleFloatingClose(pane.instance.instanceId)}
1043
1047
  />
1044
1048
  </PaneInstanceProvider>
@@ -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
  : "…";