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
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  # 📉 Gloomberb
4
4
 
5
- **Extensible financial terminal, for the terminal.**
5
+ **Modern extensible financial terminal.**
6
6
 
7
- > Why pay for Bloomberg when you can have Gloomberb?
7
+ > The Bloomberb Terminal for the rest of us.
8
8
 
9
9
  <img src="https://gloomberb.com/landing-terminal.png" alt="Gloomberb terminal screenshot" width="720" />
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { useKeyboard, useRenderer } from "@opentui/react";
3
3
  import type { CliRenderer } from "@opentui/core";
4
4
  import {
@@ -10,6 +10,7 @@ import {
10
10
  useAppState,
11
11
  } from "./state/app-context";
12
12
  import { bindAppActivity, useAppActive } from "./state/app-activity";
13
+ import { copyActiveSelection, isCopyShortcut, isPasteShortcut, pasteSystemClipboard } from "./utils/selection-clipboard";
13
14
  import { Header } from "./components/layout/header";
14
15
  import { StatusBar } from "./components/layout/status-bar";
15
16
  import { Shell } from "./components/layout/shell";
@@ -20,12 +21,15 @@ import { PluginRegistry } from "./plugins/registry";
20
21
  import { AppPersistence } from "./data/app-persistence";
21
22
  import { TickerRepository } from "./data/ticker-repository";
22
23
  import { ProviderRouter } from "./sources/provider-router";
23
- import { colors, applyTheme } from "./theme/colors";
24
+ import { colors, syncTheme } from "./theme/colors";
24
25
  import {
25
26
  createPaneInstance,
26
27
  findPaneInstance,
28
+ findPrimaryPaneInstance,
27
29
  isTickerPaneId,
28
30
  normalizePaneLayout,
31
+ resolveFollowBindingInstance,
32
+ resolvePaneInstance,
29
33
  type AppConfig,
30
34
  type BrokerInstanceConfig,
31
35
  type LayoutConfig,
@@ -51,7 +55,7 @@ import { setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
51
55
  import { getLoadablePlugins } from "./plugins/catalog";
52
56
  import { saveConfig } from "./data/config-store";
53
57
  import { Toaster, toast } from "@opentui-ui/toast/react";
54
- import { canSelfUpdate, checkForUpdateDetailed, performUpdate } from "./updater";
58
+ import { canSelfUpdate, checkForUpdateDetailed, performUpdate, type ReleaseInfo } from "./updater";
55
59
  import { VERSION } from "./version";
56
60
  import { join } from "path";
57
61
  import {
@@ -93,6 +97,7 @@ import { debugLog } from "./utils/debug-log";
93
97
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./market-data/coordinator";
94
98
  import { instrumentFromTicker } from "./market-data/request-types";
95
99
  import { syncBrokerInstance } from "./brokers/sync-broker-instance";
100
+ import { createAppNotifier } from "./notifications/app-notifier";
96
101
 
97
102
  /** Global-level dedup: prevents concurrent refresh calls for the same symbol. */
98
103
  const refreshInFlight: Set<string> = (globalThis as any).__refreshInFlight ??= new Set<string>();
@@ -100,6 +105,14 @@ const quoteRefreshInFlight: Set<string> = (globalThis as any).__quoteRefreshInFl
100
105
  const PANEL_RESOLUTION_BOUNDS = { x: 0, y: 0, width: 120, height: 40 };
101
106
  const appLog = debugLog.createLogger("app");
102
107
 
108
+ function isCollectionPaneInstance(instance: PaneInstanceConfig): boolean {
109
+ return instance.paneId === "portfolio-list";
110
+ }
111
+
112
+ function isTickerContextPaneInstance(instance: PaneInstanceConfig): boolean {
113
+ return instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId);
114
+ }
115
+
103
116
  function summarizeError(error: unknown): Record<string, string> {
104
117
  if (error instanceof Error) {
105
118
  return {
@@ -122,30 +135,45 @@ interface AppInnerProps {
122
135
  function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData, sessionSnapshot = null }: AppInnerProps) {
123
136
  const { state, dispatch } = useAppState();
124
137
  const appActive = useAppActive();
138
+ const appActiveRef = useRef(appActive);
125
139
  const renderer = useRenderer();
126
140
  const dialog = useDialog();
127
141
  const stateRef = useRef(state);
128
142
  stateRef.current = state;
143
+ appActiveRef.current = appActive;
129
144
  const focusedCollectionId = getFocusedCollectionId(state);
130
-
131
- const resolvePrimaryPaneInstanceId = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
132
- const instances = layout.instances.filter((instance) => instance.paneId === paneId);
133
- if (instances.length === 0) return null;
134
- if (isTickerPaneId(paneId)) {
135
- return instances.find((instance) =>
136
- instance.instanceId === `${paneId}:main` && instance.binding?.kind !== "fixed",
137
- )?.instanceId
138
- ?? instances.find((instance) => instance.binding?.kind !== "fixed")?.instanceId
139
- ?? null;
140
- }
141
- return instances[0]?.instanceId ?? null;
142
- }, [state.config.layout]);
145
+ const appNotifier = useMemo(() => createAppNotifier({
146
+ isAppActive: () => appActiveRef.current,
147
+ renderToast: (notification) => {
148
+ const type = notification.type ?? "info";
149
+ let toastId: string | number | undefined;
150
+ const options = {
151
+ duration: notification.duration,
152
+ action: notification.action
153
+ ? {
154
+ label: notification.action.label,
155
+ onClick: () => {
156
+ try {
157
+ notification.action?.onClick();
158
+ } finally {
159
+ if (toastId != null) toast.dismiss(toastId);
160
+ }
161
+ },
162
+ }
163
+ : undefined,
164
+ };
165
+ if (type === "success") toastId = toast.success(notification.body, options);
166
+ else if (type === "error") toastId = toast.error(notification.body, options);
167
+ else toastId = toast.info(notification.body, options);
168
+ },
169
+ }), []);
170
+ const notify = useCallback((body: string, options?: { type?: "info" | "success" | "error" }) => {
171
+ pluginRegistry.notify({ body, ...options });
172
+ }, [pluginRegistry]);
143
173
 
144
174
  const resolvePaneTarget = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
145
- const byInstance = layout.instances.find((instance) => instance.instanceId === paneId);
146
- if (byInstance) return byInstance.instanceId;
147
- return resolvePrimaryPaneInstanceId(paneId, layout);
148
- }, [resolvePrimaryPaneInstanceId, state.config.layout]);
175
+ return resolvePaneInstance(layout, paneId)?.instanceId ?? null;
176
+ }, [state.config.layout]);
149
177
 
150
178
  const getPreferredPortfolio = useCallback((ticker: TickerRecord | null) => {
151
179
  const focusedPortfolio = state.config.portfolios.find((portfolio) => portfolio.id === focusedCollectionId);
@@ -197,39 +225,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
197
225
  }, [dialog]);
198
226
 
199
227
  const resolveCollectionSourcePaneId = useCallback((preferredPaneId?: string | null) => {
200
- const tryResolve = (candidate: string | null | undefined): string | null => {
201
- if (!candidate) return null;
202
- const instance = findPaneInstance(state.config.layout, candidate);
203
- if (!instance) return null;
204
- if (instance.paneId === "portfolio-list") return instance.instanceId;
205
- if (instance.binding?.kind === "follow") {
206
- return tryResolve(instance.binding.sourceInstanceId);
207
- }
208
- return null;
209
- };
210
-
211
- return tryResolve(preferredPaneId)
212
- ?? tryResolve(state.focusedPaneId)
213
- ?? state.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
228
+ return resolveFollowBindingInstance(state.config.layout, preferredPaneId, isCollectionPaneInstance)?.instanceId
229
+ ?? resolveFollowBindingInstance(state.config.layout, state.focusedPaneId, isCollectionPaneInstance)?.instanceId
230
+ ?? findPrimaryPaneInstance(state.config.layout, "portfolio-list")?.instanceId
214
231
  ?? null;
215
232
  }, [state.config.layout, state.focusedPaneId]);
216
233
 
217
234
  const resolveTickerContextSourcePaneId = useCallback((preferredPaneId?: string | null) => {
218
- const tryResolve = (candidate: string | null | undefined): string | null => {
219
- if (!candidate) return null;
220
- const instance = findPaneInstance(state.config.layout, candidate);
221
- if (!instance) return null;
222
- if (instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId)) return instance.instanceId;
223
- if (instance.binding?.kind === "follow") {
224
- return tryResolve(instance.binding.sourceInstanceId);
225
- }
226
- return null;
227
- };
228
-
229
- return tryResolve(preferredPaneId)
230
- ?? tryResolve(state.focusedPaneId)
231
- ?? state.config.layout.instances.find((instance) => instance.paneId === "ticker-detail" && instance.binding?.kind !== "fixed")?.instanceId
232
- ?? state.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
235
+ return resolveFollowBindingInstance(state.config.layout, preferredPaneId, isTickerContextPaneInstance)?.instanceId
236
+ ?? resolveFollowBindingInstance(state.config.layout, state.focusedPaneId, isTickerContextPaneInstance)?.instanceId
237
+ ?? findPrimaryPaneInstance(state.config.layout, "ticker-detail")?.instanceId
238
+ ?? findPrimaryPaneInstance(state.config.layout, "portfolio-list")?.instanceId
233
239
  ?? null;
234
240
  }, [state.config.layout, state.focusedPaneId]);
235
241
 
@@ -273,9 +279,9 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
273
279
 
274
280
  const switchDetailTab = useCallback((tabId: string, preferredPaneId?: string | null) => {
275
281
  const targetPaneId = (() => {
276
- const target = preferredPaneId ? findPaneInstance(state.config.layout, preferredPaneId) : null;
282
+ const target = preferredPaneId ? resolvePaneInstance(state.config.layout, preferredPaneId) : null;
277
283
  if (target?.paneId === "ticker-detail") return target.instanceId;
278
- const focused = state.focusedPaneId ? findPaneInstance(state.config.layout, state.focusedPaneId) : null;
284
+ const focused = state.focusedPaneId ? resolvePaneInstance(state.config.layout, state.focusedPaneId) : null;
279
285
  if (focused?.paneId === "ticker-detail") return focused.instanceId;
280
286
  const sourcePaneId = resolveCollectionSourcePaneId(preferredPaneId);
281
287
  if (!sourcePaneId) return null;
@@ -525,6 +531,13 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
525
531
  }
526
532
  }, [state.config.brokerInstances, importBrokerPositions]);
527
533
 
534
+ const startUpdate = useCallback((release: ReleaseInfo) => {
535
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
536
+ void performUpdate(release, (progress) => {
537
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress });
538
+ });
539
+ }, [dispatch]);
540
+
528
541
  const runUpdateCheck = useCallback(async (manual = false) => {
529
542
  if (manual) {
530
543
  dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
@@ -566,6 +579,12 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
566
579
  void runUpdateCheck(false);
567
580
  }, [runUpdateCheck]);
568
581
 
582
+ useEffect(() => {
583
+ if (!state.updateAvailable || state.updateProgress || state.updateCheckInProgress) return;
584
+ if (!canSelfUpdate(state.updateAvailable)) return;
585
+ startUpdate(state.updateAvailable);
586
+ }, [startUpdate, state.updateAvailable, state.updateCheckInProgress, state.updateProgress]);
587
+
569
588
  // Load tickers on mount
570
589
  useEffect(() => {
571
590
  if (state.initialized || (globalThis as any).__gloomInitStarted) return;
@@ -783,6 +802,14 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
783
802
  pluginRegistry.switchPanelFn = (panel) => dispatch({ type: "SET_ACTIVE_PANEL", panel });
784
803
  pluginRegistry.switchTabFn = (tabId, paneId) => switchDetailTab(tabId, paneId);
785
804
  pluginRegistry.openCommandBarFn = (query) => dispatch({ type: "SET_COMMAND_BAR", open: true, query });
805
+ pluginRegistry.openPluginCommandWorkflowFn = (commandId) => {
806
+ dispatch({
807
+ type: "SET_COMMAND_BAR",
808
+ open: true,
809
+ query: "",
810
+ launch: { kind: "plugin-command", commandId },
811
+ });
812
+ };
786
813
  const persistLayout = (layout: LayoutConfig, options?: { pushHistory?: boolean }) => {
787
814
  const currentState = stateRef.current;
788
815
  const normalizedLayout = normalizePaneLayout(layout);
@@ -881,7 +908,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
881
908
  if (paneId === "ticker-detail") {
882
909
  const sourcePaneId = resolveCollectionSourcePaneId();
883
910
  if (!sourcePaneId) {
884
- pluginRegistry.showToastFn("Open a collection pane first to inspect a ticker.");
911
+ notify("Open a collection pane first to inspect a ticker.");
885
912
  return;
886
913
  }
887
914
  const ensured = ensureInspectorPane(sourcePaneId);
@@ -904,7 +931,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
904
931
  : buildPaneInstance(paneId);
905
932
  if (!instance) {
906
933
  if (isTickerPaneId(paneId)) {
907
- pluginRegistry.showToastFn("Open a ticker or collection context first.");
934
+ notify("Open a ticker or collection context first.");
908
935
  }
909
936
  return;
910
937
  }
@@ -947,7 +974,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
947
974
  placePaneInstance,
948
975
  });
949
976
  } catch (error) {
950
- pluginRegistry.showToastFn(
977
+ notify(
951
978
  error instanceof Error ? error.message : `Could not create ${getPaneTemplateDisplayLabel(template).toLowerCase()}.`,
952
979
  { type: "info" },
953
980
  );
@@ -956,6 +983,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
956
983
  buildPaneInstance,
957
984
  dataProvider,
958
985
  dispatch,
986
+ notify,
959
987
  placePaneInstance,
960
988
  pluginRegistry,
961
989
  runPaneTemplateWizard,
@@ -1067,6 +1095,90 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1067
1095
  activatePane(instance.instanceId, nextLayout);
1068
1096
  };
1069
1097
 
1098
+ pluginRegistry.navigateTickerFn = (rawSymbol) => {
1099
+ (async () => {
1100
+ try {
1101
+ // Resolve or create the ticker in the local database
1102
+ const resolved = await resolveTickerSearch({
1103
+ query: rawSymbol,
1104
+ activeTicker: null,
1105
+ tickers: stateRef.current.tickers,
1106
+ dataProvider,
1107
+ });
1108
+
1109
+ let symbol = rawSymbol;
1110
+ if (resolved?.kind === "local") {
1111
+ symbol = resolved.symbol;
1112
+ } else if (resolved?.kind === "provider" && resolved.result) {
1113
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1114
+ symbol = ticker.metadata.ticker;
1115
+ dispatch({ type: "UPDATE_TICKER", ticker });
1116
+ if (created) {
1117
+ pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1118
+ }
1119
+ }
1120
+
1121
+ // Active panel resolution — navigate the focused or linked detail pane:
1122
+ // 1. If the focused pane IS a ticker-detail, retarget it directly
1123
+ // 2. If a ticker-detail follows the focused pane, retarget that
1124
+ // 3. Any follow-mode ticker-detail in the layout
1125
+ // 4. Any ticker-detail in the layout
1126
+ // 5. Fall back to pinning a new pane
1127
+ const currentState = stateRef.current;
1128
+ const currentLayout = currentState.config.layout;
1129
+ const focused = currentState.focusedPaneId;
1130
+
1131
+ const focusedInstance = focused
1132
+ ? findPaneInstance(currentLayout, focused)
1133
+ : null;
1134
+
1135
+ const detailPane =
1136
+ (focusedInstance?.paneId === "ticker-detail" && isPaneInLayout(currentLayout, focusedInstance.instanceId)
1137
+ ? focusedInstance
1138
+ : null)
1139
+ ?? currentLayout.instances.find((inst) =>
1140
+ inst.paneId === "ticker-detail"
1141
+ && inst.binding?.kind === "follow"
1142
+ && inst.binding.sourceInstanceId === focused
1143
+ && isPaneInLayout(currentLayout, inst.instanceId),
1144
+ )
1145
+ ?? currentLayout.instances.find((inst) =>
1146
+ inst.paneId === "ticker-detail"
1147
+ && inst.binding?.kind === "follow"
1148
+ && isPaneInLayout(currentLayout, inst.instanceId),
1149
+ )
1150
+ ?? currentLayout.instances.find((inst) =>
1151
+ inst.paneId === "ticker-detail"
1152
+ && isPaneInLayout(currentLayout, inst.instanceId),
1153
+ );
1154
+
1155
+ if (detailPane) {
1156
+ if (detailPane.binding?.kind === "follow") {
1157
+ const sourceId = detailPane.binding.sourceInstanceId;
1158
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: sourceId, patch: { cursorSymbol: symbol } });
1159
+ activatePane(detailPane.instanceId, currentLayout);
1160
+ } else {
1161
+ const nextLayout = {
1162
+ ...currentLayout,
1163
+ instances: currentLayout.instances.map((instance) => (
1164
+ instance.instanceId === detailPane.instanceId
1165
+ ? { ...instance, title: symbol, binding: { kind: "fixed" as const, symbol } }
1166
+ : instance
1167
+ )),
1168
+ };
1169
+ persistLayout(nextLayout);
1170
+ activatePane(detailPane.instanceId, nextLayout);
1171
+ }
1172
+ } else {
1173
+ pluginRegistry.pinTickerFn(symbol, { floating: false });
1174
+ }
1175
+ } catch (err) {
1176
+ const message = err instanceof Error ? err.message : String(err);
1177
+ pluginRegistry.notify({ body: `Failed to navigate to ${rawSymbol}: ${message}`, type: "error" });
1178
+ }
1179
+ })();
1180
+ };
1181
+
1070
1182
  setLayoutManagerDispatch(dispatch, () => ({
1071
1183
  layout: state.config.layout,
1072
1184
  termWidth: pluginRegistry.getTermSizeFn().width,
@@ -1074,14 +1186,8 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1074
1186
  focusedPaneId: state.focusedPaneId,
1075
1187
  }));
1076
1188
 
1077
- // Wire up toast
1078
- pluginRegistry.showToastFn = (message, options) => {
1079
- const type = options?.type ?? "info";
1080
- const duration = options?.duration;
1081
- if (type === "success") toast.success(message, { duration });
1082
- else if (type === "error") toast.error(message, { duration });
1083
- else toast.info(message, { duration });
1084
- };
1189
+ // Wire up app-level notifications.
1190
+ pluginRegistry.notifyFn = appNotifier.notify;
1085
1191
 
1086
1192
  // Persist layout changes (switching, saving, deleting, renaming layouts)
1087
1193
  const prevLayouts = useRef(state.config.layouts);
@@ -1111,6 +1217,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1111
1217
 
1112
1218
  // Global keyboard shortcuts
1113
1219
  useKeyboard((event) => {
1220
+ if (isCopyShortcut(event) && copyActiveSelection(renderer)) {
1221
+ event.preventDefault();
1222
+ event.stopPropagation();
1223
+ return;
1224
+ }
1225
+ if (isPasteShortcut(event) && pasteSystemClipboard(renderer)) {
1226
+ event.preventDefault();
1227
+ event.stopPropagation();
1228
+ return;
1229
+ }
1230
+
1114
1231
  // Skip all global shortcuts when a dialog is open
1115
1232
  if (dialogOpen) return;
1116
1233
 
@@ -1172,9 +1289,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1172
1289
  dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
1173
1290
  }
1174
1291
  } else if (event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
1175
- performUpdate(state.updateAvailable, (progress) => {
1176
- dispatch({ type: "SET_UPDATE_PROGRESS", progress });
1177
- });
1292
+ startUpdate(state.updateAvailable);
1178
1293
  } else {
1179
1294
  // Plugin keyboard shortcuts (built-ins take priority)
1180
1295
  const disabledPlugins = new Set(state.config.disabledPlugins || []);
@@ -1239,11 +1354,10 @@ export function App({
1239
1354
  });
1240
1355
  const [showOnboarding, setShowOnboarding] = useState(!initialConfig.onboardingComplete);
1241
1356
 
1242
- useEffect(() => bindAppActivity(renderer), [renderer]);
1357
+ // Keep the shared palette aligned before this render builds any JSX that reads `colors`.
1358
+ syncTheme(config.theme);
1243
1359
 
1244
- useLayoutEffect(() => {
1245
- if (config.theme) applyTheme(config.theme);
1246
- }, [config.theme]);
1360
+ useEffect(() => bindAppActivity(renderer), [renderer]);
1247
1361
 
1248
1362
  const services = useMemo(() => {
1249
1363
  const dbPath = join(config.dataDir, ".gloomberb-cache.db");
@@ -1278,6 +1392,7 @@ export function App({
1278
1392
  useEffect(() => {
1279
1393
  return () => {
1280
1394
  setSharedMarketDataCoordinator(null);
1395
+ services.pluginRegistry.destroy();
1281
1396
  services.persistence.close();
1282
1397
  };
1283
1398
  }, [services]);
@@ -4,6 +4,7 @@ import {
4
4
  formatNumber,
5
5
  formatPercent,
6
6
  } from "../../utils/format";
7
+ import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity } from "../../utils/market-format";
7
8
  import {
8
9
  cliStyles,
9
10
  colorBySign,
@@ -77,6 +78,30 @@ function appendTextSection(lines: string[], title: string, content: string | und
77
78
  lines.push(text);
78
79
  }
79
80
 
81
+ function normalizeTimestamp(value: Date | string | number | undefined): number | null {
82
+ if (value instanceof Date) {
83
+ const timestamp = value.getTime();
84
+ return Number.isNaN(timestamp) ? null : timestamp;
85
+ }
86
+
87
+ if (typeof value === "string" || typeof value === "number") {
88
+ const timestamp = new Date(value).getTime();
89
+ return Number.isNaN(timestamp) ? null : timestamp;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ function formatFeedDate(value: Date | string | number | undefined): string {
96
+ const timestamp = normalizeTimestamp(value);
97
+ if (timestamp == null) return "";
98
+ return new Date(timestamp).toLocaleDateString("en-US", {
99
+ month: "short",
100
+ day: "numeric",
101
+ year: "numeric",
102
+ });
103
+ }
104
+
80
105
  function appendFeedSection(
81
106
  lines: string[],
82
107
  title: string,
@@ -215,16 +240,16 @@ export async function buildTickerReport({
215
240
  : "—";
216
241
 
217
242
  appendMetricSection(lines, "Quote", [
218
- ["Last", colorBySign(formatCurrency(quote.price, quote.currency), quote.change)],
243
+ ["Last", colorBySign(formatMarketPriceWithCurrency(quote.price, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }), quote.change)],
219
244
  ["Change", colorBySign(`${formatSignedCurrency(quote.change, quote.currency)} (${formatSignedPercentRaw(quote.changePercent)})`, quote.change)],
220
- ["Open", quote.open != null ? formatCurrency(quote.open, quote.currency) : "—"],
245
+ ["Open", quote.open != null ? formatMarketPriceWithCurrency(quote.open, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"],
221
246
  ["Day Range", quote.low != null || quote.high != null
222
- ? `${quote.low != null ? formatCurrency(quote.low, quote.currency) : "—"} - ${quote.high != null ? formatCurrency(quote.high, quote.currency) : "—"}`
247
+ ? `${quote.low != null ? formatMarketPriceWithCurrency(quote.low, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"} - ${quote.high != null ? formatMarketPriceWithCurrency(quote.high, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"}`
223
248
  : "—"],
224
249
  ["52W Range", quote.low52w != null || quote.high52w != null
225
- ? `${quote.low52w != null ? formatCurrency(quote.low52w, quote.currency) : "—"} - ${quote.high52w != null ? formatCurrency(quote.high52w, quote.currency) : "—"}`
250
+ ? `${quote.low52w != null ? formatMarketPriceWithCurrency(quote.low52w, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"} - ${quote.high52w != null ? formatMarketPriceWithCurrency(quote.high52w, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory }) : "—"}`
226
251
  : "—"],
227
- ["Bid / Ask", formatBidAsk(quote.bid, quote.ask, quote.bidSize, quote.askSize, quote.currency)],
252
+ ["Bid / Ask", formatBidAsk(quote.bid, quote.ask, quote.bidSize, quote.askSize, quote.currency, tickerFile?.metadata.assetCategory)],
228
253
  ["Volume", quote.volume != null ? formatNumber(quote.volume, 0) : "—"],
229
254
  ["Updated", formatTimestamp(quote.lastUpdated)],
230
255
  ]);
@@ -232,13 +257,13 @@ export async function buildTickerReport({
232
257
  appendMetricSection(lines, "Extended Hours", [
233
258
  ["Pre-Market", quote.preMarketPrice != null
234
259
  ? colorBySign(
235
- `${formatCurrency(quote.preMarketPrice, quote.currency)} (${formatSignedPercentRaw(quote.preMarketChangePercent ?? 0)})`,
260
+ `${formatMarketPriceWithCurrency(quote.preMarketPrice, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory })} (${formatSignedPercentRaw(quote.preMarketChangePercent ?? 0)})`,
236
261
  quote.preMarketChange ?? 0,
237
262
  )
238
263
  : "—"],
239
264
  ["After Hours", quote.postMarketPrice != null
240
265
  ? colorBySign(
241
- `${formatCurrency(quote.postMarketPrice, quote.currency)} (${formatSignedPercentRaw(quote.postMarketChangePercent ?? 0)})`,
266
+ `${formatMarketPriceWithCurrency(quote.postMarketPrice, quote.currency, { assetCategory: tickerFile?.metadata.assetCategory })} (${formatSignedPercentRaw(quote.postMarketChangePercent ?? 0)})`,
242
267
  quote.postMarketChange ?? 0,
243
268
  )
244
269
  : "—"],
@@ -288,18 +313,20 @@ export async function buildTickerReport({
288
313
  title: item.title,
289
314
  meta: [
290
315
  item.source,
291
- Number.isNaN(item.publishedAt.getTime()) ? "" : formatTimestamp(item.publishedAt.getTime()),
316
+ (() => {
317
+ const publishedAt = normalizeTimestamp(item.publishedAt as Date | string | number | undefined);
318
+ return publishedAt == null ? "" : formatTimestamp(publishedAt);
319
+ })(),
292
320
  ],
293
321
  body: item.summary,
294
322
  link: item.url,
295
323
  })));
296
324
 
297
325
  appendFeedSection(lines, "Recent SEC Filings", recentSecFilings.map((filing) => ({
298
- title: `${filing.form} | ${filing.filingDate.toLocaleDateString("en-US", {
299
- month: "short",
300
- day: "numeric",
301
- year: "numeric",
302
- })}`,
326
+ title: (() => {
327
+ const filingDate = formatFeedDate(filing.filingDate as Date | string | number | undefined);
328
+ return filingDate ? `${filing.form} | ${filingDate}` : filing.form;
329
+ })(),
303
330
  meta: [
304
331
  filing.items ? `Items ${filing.items}` : "",
305
332
  getFilingDescription(filing) ?? "",
@@ -320,12 +347,15 @@ export async function buildTickerReport({
320
347
  const pnl = marketValueBase - costBasisBase;
321
348
 
322
349
  lines.push(cliStyles.bold(`${portfolioName} (${position.broker})`));
323
- lines.push(renderStat("Position", `${position.shares} ${multiplier > 1 ? "contracts" : "shares"} @ ${formatCurrency(position.avgCost, positionCurrency)}`));
350
+ lines.push(renderStat(
351
+ "Position",
352
+ `${formatMarketQuantity(position.shares, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })} ${multiplier > 1 ? "contracts" : "shares"} @ ${formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })}`,
353
+ ));
324
354
  lines.push(renderStat("Cost Basis", formatCurrency(costBasisBase, config.baseCurrency)));
325
355
  lines.push(renderStat("Market Value", formatCurrency(marketValueBase, config.baseCurrency)));
326
356
  lines.push(renderStat("P&L", colorBySign(formatSignedCurrency(pnl, config.baseCurrency), pnl)));
327
357
  if (position.markPrice != null) {
328
- lines.push(renderStat("Mark", formatCurrency(position.markPrice, positionCurrency)));
358
+ lines.push(renderStat("Mark", formatMarketPriceWithCurrency(position.markPrice, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier })));
329
359
  }
330
360
  if (index < tickerFile.metadata.positions.length - 1) {
331
361
  lines.push(cliStyles.muted("-".repeat(24)));
@@ -4,6 +4,7 @@ import {
4
4
  formatNumber,
5
5
  formatPercentRaw,
6
6
  } from "../utils/format";
7
+ import { formatMarketPriceWithCurrency } from "../utils/market-format";
7
8
  export { slugifyName } from "../utils/slugify";
8
9
  import type { AppConfig } from "../types/config";
9
10
  import type { Watchlist, TickerRecord } from "../types/ticker";
@@ -42,13 +43,14 @@ export function formatBidAsk(
42
43
  bidSize: number | undefined,
43
44
  askSize: number | undefined,
44
45
  currency: string,
46
+ assetCategory?: string,
45
47
  ): string {
46
48
  if (bid == null && ask == null) return "—";
47
49
  const bidText = bid != null
48
- ? `${formatCurrency(bid, currency)}${bidSize != null ? ` x ${formatNumber(bidSize, 0)}` : ""}`
50
+ ? `${formatMarketPriceWithCurrency(bid, currency, { assetCategory })}${bidSize != null ? ` x ${formatNumber(bidSize, 0)}` : ""}`
49
51
  : "—";
50
52
  const askText = ask != null
51
- ? `${formatCurrency(ask, currency)}${askSize != null ? ` x ${formatNumber(askSize, 0)}` : ""}`
53
+ ? `${formatMarketPriceWithCurrency(ask, currency, { assetCategory })}${askSize != null ? ` x ${formatNumber(askSize, 0)}` : ""}`
52
54
  : "—";
53
55
  return `${bidText} / ${askText}`;
54
56
  }