gloomberb 0.2.0 → 0.3.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 (310) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1330 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  45. package/src/components/command-bar/command-bar.tsx +4823 -0
  46. package/src/components/command-bar/command-registry.ts +261 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +129 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +625 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  70. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  71. package/src/components/pane-settings-dialog.tsx +468 -0
  72. package/src/components/pane-template-wizard.test.tsx +52 -0
  73. package/src/components/pane-template-wizard.tsx +199 -0
  74. package/src/components/price-selector-dialog.tsx +195 -0
  75. package/src/components/spinner.tsx +2 -0
  76. package/src/components/tab-bar.tsx +17 -0
  77. package/src/components/ticker-badge-text.test.tsx +160 -0
  78. package/src/components/ticker-badge-text.tsx +58 -0
  79. package/src/components/ticker-badge.tsx +88 -0
  80. package/src/components/ticker-list-table.tsx +188 -0
  81. package/src/components/toggle-list.tsx +74 -0
  82. package/src/components/ui/button.tsx +77 -0
  83. package/src/components/ui/data-table.tsx +210 -0
  84. package/src/components/ui/external-link.tsx +22 -0
  85. package/src/components/ui/fields.tsx +187 -0
  86. package/src/components/ui/frame.tsx +73 -0
  87. package/src/components/ui/index.ts +53 -0
  88. package/src/components/ui/list-view.tsx +166 -0
  89. package/src/components/ui/loading.tsx +70 -0
  90. package/src/components/ui/status.tsx +83 -0
  91. package/src/components/ui/tabs.tsx +47 -0
  92. package/src/components/ui/toggle.tsx +164 -0
  93. package/src/components/ui/ui.test.tsx +134 -0
  94. package/src/data/app-persistence.ts +25 -0
  95. package/src/data/config-store.test.ts +310 -0
  96. package/src/data/config-store.ts +469 -0
  97. package/src/data/plugin-state-store.ts +102 -0
  98. package/src/data/resource-store.ts +323 -0
  99. package/src/data/session-store.ts +52 -0
  100. package/src/data/sqlite-cache.test.ts +103 -0
  101. package/src/data/sqlite-database.ts +62 -0
  102. package/src/data/sqlite-json.ts +19 -0
  103. package/src/data/ticker-repository.ts +45 -0
  104. package/src/data/ticker-store.ts +46 -0
  105. package/src/index.tsx +67 -0
  106. package/src/market-data/coordinator.test.ts +369 -0
  107. package/src/market-data/coordinator.ts +580 -0
  108. package/src/market-data/hooks.tsx +234 -0
  109. package/src/market-data/query-store.ts +28 -0
  110. package/src/market-data/request-types.test.ts +64 -0
  111. package/src/market-data/request-types.ts +70 -0
  112. package/src/market-data/result-types.ts +56 -0
  113. package/src/market-data/selectors.ts +166 -0
  114. package/src/market-data/trace.ts +7 -0
  115. package/src/pane-settings.test.ts +71 -0
  116. package/src/pane-settings.ts +73 -0
  117. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  118. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  119. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  120. package/src/plugins/builtin/ai/index.tsx +86 -0
  121. package/src/plugins/builtin/ai/providers.ts +99 -0
  122. package/src/plugins/builtin/ai/runner.ts +82 -0
  123. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  124. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  125. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  126. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  127. package/src/plugins/builtin/ai/settings.ts +79 -0
  128. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  129. package/src/plugins/builtin/ai/utils.ts +71 -0
  130. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  131. package/src/plugins/builtin/ask-ai.tsx +9 -0
  132. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  133. package/src/plugins/builtin/chat-controller.ts +340 -0
  134. package/src/plugins/builtin/chat.test.tsx +451 -0
  135. package/src/plugins/builtin/chat.tsx +620 -0
  136. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  137. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  138. package/src/plugins/builtin/data-providers.ts +16 -0
  139. package/src/plugins/builtin/debug.tsx +351 -0
  140. package/src/plugins/builtin/help.test.tsx +110 -0
  141. package/src/plugins/builtin/help.tsx +281 -0
  142. package/src/plugins/builtin/layout-manager.tsx +264 -0
  143. package/src/plugins/builtin/manual-entry.tsx +25 -0
  144. package/src/plugins/builtin/news.tsx +143 -0
  145. package/src/plugins/builtin/notes-files.ts +58 -0
  146. package/src/plugins/builtin/notes.tsx +396 -0
  147. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  148. package/src/plugins/builtin/options-availability.ts +158 -0
  149. package/src/plugins/builtin/options.tsx +364 -0
  150. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  151. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  152. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  153. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  154. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  155. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  156. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  157. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  158. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  159. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  160. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  161. package/src/plugins/builtin/sec.tsx +250 -0
  162. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  163. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  164. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  165. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  166. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  167. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  168. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  169. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  170. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  171. package/src/plugins/builtin/yahoo.ts +18 -0
  172. package/src/plugins/event-bus.ts +37 -0
  173. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  174. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  175. package/src/plugins/ibkr/account-cache.ts +137 -0
  176. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  177. package/src/plugins/ibkr/config.test.ts +68 -0
  178. package/src/plugins/ibkr/config.ts +262 -0
  179. package/src/plugins/ibkr/dialogs.tsx +83 -0
  180. package/src/plugins/ibkr/flex.test.ts +107 -0
  181. package/src/plugins/ibkr/flex.ts +267 -0
  182. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  183. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  184. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  185. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  186. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  187. package/src/plugins/ibkr/index.test.tsx +183 -0
  188. package/src/plugins/ibkr/index.tsx +153 -0
  189. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  190. package/src/plugins/ibkr/instance-selection.ts +36 -0
  191. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  192. package/src/plugins/ibkr/price-normalization.ts +59 -0
  193. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  194. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  195. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  196. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  197. package/src/plugins/ibkr/trade-utils.ts +149 -0
  198. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  199. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  200. package/src/plugins/ibkr/trading-state.ts +339 -0
  201. package/src/plugins/loader.ts +71 -0
  202. package/src/plugins/pane-manager.test.ts +103 -0
  203. package/src/plugins/pane-manager.ts +974 -0
  204. package/src/plugins/plugin-persistence.ts +85 -0
  205. package/src/plugins/plugin-runtime.test.tsx +185 -0
  206. package/src/plugins/plugin-runtime.tsx +175 -0
  207. package/src/plugins/prediction-markets/cache.ts +114 -0
  208. package/src/plugins/prediction-markets/categories.ts +223 -0
  209. package/src/plugins/prediction-markets/chart.tsx +512 -0
  210. package/src/plugins/prediction-markets/columns.ts +133 -0
  211. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  212. package/src/plugins/prediction-markets/controller.ts +725 -0
  213. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  214. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  215. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  216. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  217. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  218. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  219. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  220. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  221. package/src/plugins/prediction-markets/index.tsx +102 -0
  222. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  223. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  224. package/src/plugins/prediction-markets/launch.ts +240 -0
  225. package/src/plugins/prediction-markets/metrics.ts +311 -0
  226. package/src/plugins/prediction-markets/navigation.ts +90 -0
  227. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  228. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  229. package/src/plugins/prediction-markets/pane.tsx +294 -0
  230. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  231. package/src/plugins/prediction-markets/rows.ts +229 -0
  232. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  233. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  234. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  235. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  236. package/src/plugins/prediction-markets/settings.ts +164 -0
  237. package/src/plugins/prediction-markets/status.ts +24 -0
  238. package/src/plugins/prediction-markets/table.tsx +84 -0
  239. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  240. package/src/plugins/prediction-markets/types.ts +186 -0
  241. package/src/plugins/registry.ts +633 -0
  242. package/src/sources/exchange-rates.ts +18 -0
  243. package/src/sources/gloomberb-cloud.test.ts +199 -0
  244. package/src/sources/gloomberb-cloud.ts +326 -0
  245. package/src/sources/provider-errors.ts +19 -0
  246. package/src/sources/provider-router.test.ts +1476 -0
  247. package/src/sources/provider-router.ts +1405 -0
  248. package/src/sources/sec-edgar.test.ts +286 -0
  249. package/src/sources/sec-edgar.ts +526 -0
  250. package/src/sources/yahoo-finance.test.ts +14 -0
  251. package/src/sources/yahoo-finance.ts +1017 -0
  252. package/src/state/app-activity.test.ts +41 -0
  253. package/src/state/app-activity.ts +106 -0
  254. package/src/state/app-bootstrap.test.ts +471 -0
  255. package/src/state/app-bootstrap.ts +308 -0
  256. package/src/state/app-context.test.ts +406 -0
  257. package/src/state/app-context.test.tsx +132 -0
  258. package/src/state/app-context.tsx +969 -0
  259. package/src/state/selectors.ts +67 -0
  260. package/src/state/session-persistence.test.ts +128 -0
  261. package/src/state/session-persistence.ts +161 -0
  262. package/src/state/ticker-refresh-queue.test.ts +46 -0
  263. package/src/state/ticker-refresh-queue.ts +40 -0
  264. package/src/state/use-inline-tickers.ts +174 -0
  265. package/src/state/use-quote-streaming.test.ts +37 -0
  266. package/src/state/use-quote-streaming.test.tsx +70 -0
  267. package/src/state/use-quote-streaming.ts +80 -0
  268. package/src/state/use-session-snapshot.ts +84 -0
  269. package/src/test-support/data-provider.ts +22 -0
  270. package/src/theme/color-utils.ts +67 -0
  271. package/src/theme/colors.ts +128 -0
  272. package/src/theme/themes.test.ts +45 -0
  273. package/src/theme/themes.ts +491 -0
  274. package/src/types/broker.ts +105 -0
  275. package/src/types/config.ts +434 -0
  276. package/src/types/data-provider.ts +81 -0
  277. package/src/types/financials.ts +142 -0
  278. package/src/types/instrument.ts +29 -0
  279. package/src/types/persistence.ts +21 -0
  280. package/src/types/plugin.ts +354 -0
  281. package/src/types/ticker.ts +58 -0
  282. package/src/types/trading.ts +85 -0
  283. package/src/updater.test.ts +162 -0
  284. package/src/updater.ts +247 -0
  285. package/src/utils/api-client.ts +666 -0
  286. package/src/utils/ascii-chart.ts +172 -0
  287. package/src/utils/broker-instances.ts +47 -0
  288. package/src/utils/cli-output.ts +83 -0
  289. package/src/utils/currency-units.test.ts +49 -0
  290. package/src/utils/currency-units.ts +82 -0
  291. package/src/utils/debug-log.ts +133 -0
  292. package/src/utils/exchange-rate-map.ts +8 -0
  293. package/src/utils/format.ts +158 -0
  294. package/src/utils/fuzzy-search.ts +34 -0
  295. package/src/utils/market-status.ts +95 -0
  296. package/src/utils/options.ts +86 -0
  297. package/src/utils/price-history.ts +39 -0
  298. package/src/utils/quote-time.test.ts +22 -0
  299. package/src/utils/quote-time.ts +50 -0
  300. package/src/utils/sec.test.ts +55 -0
  301. package/src/utils/sec.ts +45 -0
  302. package/src/utils/ticker-list.test.ts +22 -0
  303. package/src/utils/ticker-list.ts +35 -0
  304. package/src/utils/ticker-metadata.ts +51 -0
  305. package/src/utils/ticker-search.test.ts +227 -0
  306. package/src/utils/ticker-search.ts +764 -0
  307. package/src/utils/ticker-tokenizer.test.ts +48 -0
  308. package/src/utils/ticker-tokenizer.ts +72 -0
  309. package/src/version.ts +1 -0
  310. package/install.mjs +0 -68
package/src/app.tsx ADDED
@@ -0,0 +1,1330 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
+ import { useKeyboard, useRenderer } from "@opentui/react";
3
+ import type { CliRenderer } from "@opentui/core";
4
+ import {
5
+ AppProvider,
6
+ getFocusedCollectionId,
7
+ getFocusedTickerSymbol,
8
+ resolveCollectionForPane,
9
+ resolveTickerForPane,
10
+ useAppState,
11
+ } from "./state/app-context";
12
+ import { bindAppActivity, useAppActive } from "./state/app-activity";
13
+ import { Header } from "./components/layout/header";
14
+ import { StatusBar } from "./components/layout/status-bar";
15
+ import { Shell } from "./components/layout/shell";
16
+ import { CommandBar } from "./components/command-bar/command-bar";
17
+ import { OnboardingWizard } from "./components/onboarding/onboarding-wizard";
18
+ import { DialogProvider, useDialog, useDialogState } from "@opentui-ui/dialog/react";
19
+ import { PluginRegistry } from "./plugins/registry";
20
+ import { AppPersistence } from "./data/app-persistence";
21
+ import { TickerRepository } from "./data/ticker-repository";
22
+ import { ProviderRouter } from "./sources/provider-router";
23
+ import { colors, applyTheme } from "./theme/colors";
24
+ import {
25
+ createPaneInstance,
26
+ findPaneInstance,
27
+ isTickerPaneId,
28
+ normalizePaneLayout,
29
+ type AppConfig,
30
+ type BrokerInstanceConfig,
31
+ type LayoutConfig,
32
+ type PaneBinding,
33
+ type PaneInstanceConfig,
34
+ } from "./types/config";
35
+ import type { PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
36
+ import type { PaneSettingField } from "./types/plugin";
37
+ import type { TickerRecord } from "./types/ticker";
38
+ import type { DataProvider } from "./types/data-provider";
39
+ import type { TickerFinancials } from "./types/financials";
40
+ import type { BrokerAccount } from "./types/trading";
41
+ import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
42
+
43
+ // Built-in plugins
44
+ import { portfolioListPlugin } from "./plugins/builtin/portfolio-list";
45
+ import { tickerDetailPlugin } from "./plugins/builtin/ticker-detail";
46
+ import { manualEntryPlugin } from "./plugins/builtin/manual-entry";
47
+ import { ibkrPlugin } from "./plugins/ibkr";
48
+ import {
49
+ clearPersistedIbkrAccounts,
50
+ loadPersistedIbkrAccountMap,
51
+ } from "./plugins/ibkr/account-cache";
52
+ import { getIbkrConfigIdentity } from "./plugins/ibkr/config";
53
+ import { newsPlugin } from "./plugins/builtin/news";
54
+ import { secPlugin } from "./plugins/builtin/sec";
55
+ import { optionsPlugin } from "./plugins/builtin/options";
56
+ import { notesPlugin } from "./plugins/builtin/notes";
57
+ import { aiPlugin } from "./plugins/builtin/ai/index";
58
+ import { gloomberbCloudPlugin } from "./plugins/builtin/chat";
59
+ import { chatController } from "./plugins/builtin/chat-controller";
60
+ import { helpPlugin } from "./plugins/builtin/help";
61
+ import { comparisonChartPlugin } from "./plugins/builtin/comparison-chart";
62
+ import { debugPlugin } from "./plugins/builtin/debug";
63
+ import { layoutManagerPlugin, setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
64
+ import { yahooPlugin } from "./plugins/builtin/yahoo";
65
+ import { predictionMarketsPlugin } from "./plugins/prediction-markets";
66
+ import {
67
+ applyPredictionLaunchIntentToConfig,
68
+ applyPredictionLaunchIntentToSessionSnapshot,
69
+ type PredictionLaunchIntent,
70
+ } from "./plugins/prediction-markets/launch";
71
+ import { saveConfig } from "./data/config-store";
72
+ import { Toaster, toast } from "@opentui-ui/toast/react";
73
+ import { canSelfUpdate, checkForUpdate, performUpdate } from "./updater";
74
+ import { VERSION } from "./version";
75
+ import { join } from "path";
76
+ import {
77
+ addPaneFloating,
78
+ addPaneToLayout,
79
+ bringToFront,
80
+ findDockLeaf,
81
+ getDockLeafLayouts,
82
+ getDockedPaneIds,
83
+ getLeafRect,
84
+ isPaneInLayout,
85
+ removePane,
86
+ } from "./plugins/pane-manager";
87
+ import {
88
+ createBrokerInstanceId,
89
+ getBrokerInstance,
90
+ } from "./utils/broker-instances";
91
+ import {
92
+ APP_SESSION_ID,
93
+ APP_SESSION_SCHEMA_VERSION,
94
+ reconcileAppSessionSnapshot,
95
+ type AppSessionSnapshot,
96
+ } from "./state/session-persistence";
97
+ import { initializeAppState } from "./state/app-bootstrap";
98
+ import { TickerRefreshQueue } from "./state/ticker-refresh-queue";
99
+ import { PaneSettingsDialogContent } from "./components/pane-settings-dialog";
100
+ import {
101
+ PaneTemplateInfoStep,
102
+ PaneTemplateInputStep,
103
+ PaneTemplateSelectStep,
104
+ PaneTemplateTextareaStep,
105
+ } from "./components/pane-template-wizard";
106
+ import {
107
+ applyPaneSettingFieldValue as applyPaneSettingFieldValueShared,
108
+ createPaneTemplateOrThrow,
109
+ } from "./components/command-bar/workflow-ops";
110
+ import { debugLog } from "./utils/debug-log";
111
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./market-data/coordinator";
112
+ import { instrumentFromTicker } from "./market-data/request-types";
113
+ import { syncBrokerInstance } from "./brokers/sync-broker-instance";
114
+
115
+ /** Global-level dedup: prevents concurrent refresh calls for the same symbol. */
116
+ const refreshInFlight: Set<string> = (globalThis as any).__refreshInFlight ??= new Set<string>();
117
+ const quoteRefreshInFlight: Set<string> = (globalThis as any).__quoteRefreshInFlight ??= new Set<string>();
118
+ const PANEL_RESOLUTION_BOUNDS = { x: 0, y: 0, width: 120, height: 40 };
119
+ const appLog = debugLog.createLogger("app");
120
+
121
+ function summarizeError(error: unknown): Record<string, string> {
122
+ if (error instanceof Error) {
123
+ return {
124
+ name: error.name,
125
+ message: error.message,
126
+ stack: error.stack || "",
127
+ };
128
+ }
129
+ return { message: String(error) };
130
+ }
131
+
132
+ interface AppInnerProps {
133
+ pluginRegistry: PluginRegistry;
134
+ tickerRepository: TickerRepository;
135
+ dataProvider: DataProvider;
136
+ marketData: MarketDataCoordinator;
137
+ sessionSnapshot?: AppSessionSnapshot | null;
138
+ }
139
+
140
+ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData, sessionSnapshot = null }: AppInnerProps) {
141
+ const { state, dispatch } = useAppState();
142
+ const appActive = useAppActive();
143
+ const renderer = useRenderer();
144
+ const dialog = useDialog();
145
+ const stateRef = useRef(state);
146
+ stateRef.current = state;
147
+ const focusedCollectionId = getFocusedCollectionId(state);
148
+
149
+ const resolvePrimaryPaneInstanceId = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
150
+ const instances = layout.instances.filter((instance) => instance.paneId === paneId);
151
+ if (instances.length === 0) return null;
152
+ if (isTickerPaneId(paneId)) {
153
+ return instances.find((instance) =>
154
+ instance.instanceId === `${paneId}:main` && instance.binding?.kind !== "fixed",
155
+ )?.instanceId
156
+ ?? instances.find((instance) => instance.binding?.kind !== "fixed")?.instanceId
157
+ ?? null;
158
+ }
159
+ return instances[0]?.instanceId ?? null;
160
+ }, [state.config.layout]);
161
+
162
+ const resolvePaneTarget = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
163
+ const byInstance = layout.instances.find((instance) => instance.instanceId === paneId);
164
+ if (byInstance) return byInstance.instanceId;
165
+ return resolvePrimaryPaneInstanceId(paneId, layout);
166
+ }, [resolvePrimaryPaneInstanceId, state.config.layout]);
167
+
168
+ const getPreferredPortfolio = useCallback((ticker: TickerRecord | null) => {
169
+ const focusedPortfolio = state.config.portfolios.find((portfolio) => portfolio.id === focusedCollectionId);
170
+ if (focusedPortfolio) return focusedPortfolio;
171
+ if (ticker) {
172
+ for (const portfolioId of ticker.metadata.portfolios) {
173
+ const portfolio = state.config.portfolios.find((entry) => entry.id === portfolioId);
174
+ if (portfolio) return portfolio;
175
+ }
176
+ }
177
+ return state.config.portfolios[0] ?? null;
178
+ }, [focusedCollectionId, state.config.portfolios]);
179
+
180
+ const runPaneTemplateWizard = useCallback(async (steps: WizardStep[]): Promise<Record<string, string> | null> => {
181
+ const values: Record<string, string> = {};
182
+
183
+ for (const step of steps) {
184
+ if (step.dependsOn && values[step.dependsOn.key] !== step.dependsOn.value) {
185
+ continue;
186
+ }
187
+
188
+ if (step.type === "info") {
189
+ await dialog.alert({
190
+ content: (ctx) => <PaneTemplateInfoStep {...ctx} step={step} />,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ const result = step.type === "select"
196
+ ? await dialog.prompt<string>({
197
+ content: (ctx) => <PaneTemplateSelectStep {...ctx} step={step} />,
198
+ })
199
+ : step.type === "textarea"
200
+ ? await dialog.prompt<string>({
201
+ content: (ctx) => <PaneTemplateTextareaStep {...ctx} step={step} />,
202
+ })
203
+ : await dialog.prompt<string>({
204
+ content: (ctx) => <PaneTemplateInputStep {...ctx} step={step} />,
205
+ });
206
+
207
+ if (result === undefined || ((step.type === "select" || step.type === "textarea") && !result)) {
208
+ return null;
209
+ }
210
+
211
+ values[step.key] = result;
212
+ }
213
+
214
+ return values;
215
+ }, [dialog]);
216
+
217
+ const resolveCollectionSourcePaneId = 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") 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 === "portfolio-list")?.instanceId
232
+ ?? null;
233
+ }, [state.config.layout, state.focusedPaneId]);
234
+
235
+ const resolveTickerContextSourcePaneId = useCallback((preferredPaneId?: string | null) => {
236
+ const tryResolve = (candidate: string | null | undefined): string | null => {
237
+ if (!candidate) return null;
238
+ const instance = findPaneInstance(state.config.layout, candidate);
239
+ if (!instance) return null;
240
+ if (instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId)) return instance.instanceId;
241
+ if (instance.binding?.kind === "follow") {
242
+ return tryResolve(instance.binding.sourceInstanceId);
243
+ }
244
+ return null;
245
+ };
246
+
247
+ return tryResolve(preferredPaneId)
248
+ ?? tryResolve(state.focusedPaneId)
249
+ ?? state.config.layout.instances.find((instance) => instance.paneId === "ticker-detail" && instance.binding?.kind !== "fixed")?.instanceId
250
+ ?? state.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
251
+ ?? null;
252
+ }, [state.config.layout, state.focusedPaneId]);
253
+
254
+ const selectTickerInPane = useCallback((symbol: string, preferredPaneId?: string | null) => {
255
+ const sourcePaneId = resolveCollectionSourcePaneId(preferredPaneId);
256
+ if (!sourcePaneId) return;
257
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: sourcePaneId, patch: { cursorSymbol: symbol } });
258
+ }, [dispatch, resolveCollectionSourcePaneId]);
259
+
260
+ const resolveInspectorPane = useCallback((sourcePaneId: string): PaneInstanceConfig | null => {
261
+ return state.config.layout.instances.find((instance) =>
262
+ instance.paneId === "ticker-detail"
263
+ && instance.binding?.kind === "follow"
264
+ && instance.binding.sourceInstanceId === sourcePaneId
265
+ && isPaneInLayout(state.config.layout, instance.instanceId),
266
+ ) ?? null;
267
+ }, [state.config.layout]);
268
+
269
+ const ensureInspectorPane = useCallback((sourcePaneId: string) => {
270
+ const existing = resolveInspectorPane(sourcePaneId);
271
+ if (existing) return { layout: state.config.layout, instance: existing };
272
+
273
+ const paneDef = pluginRegistry.panes.get("ticker-detail");
274
+ if (!paneDef) return null;
275
+
276
+ const preferredInstanceId = sourcePaneId === "portfolio-list:main"
277
+ && !findPaneInstance(state.config.layout, "ticker-detail:main")
278
+ ? "ticker-detail:main"
279
+ : undefined;
280
+ const instance = createPaneInstance("ticker-detail", {
281
+ instanceId: preferredInstanceId,
282
+ binding: { kind: "follow", sourceInstanceId: sourcePaneId },
283
+ });
284
+ const { width, height } = pluginRegistry.getTermSizeFn();
285
+ const sourceDocked = findDockLeaf(state.config.layout, sourcePaneId);
286
+ const layout = sourceDocked && paneDef.defaultMode !== "floating"
287
+ ? addPaneToLayout(state.config.layout, instance, { relativeTo: sourcePaneId, position: "right" })
288
+ : addPaneFloating(state.config.layout, instance, width, height, paneDef);
289
+ return { layout, instance };
290
+ }, [pluginRegistry.panes, pluginRegistry.getTermSizeFn, resolveInspectorPane, state.config.layout]);
291
+
292
+ const switchDetailTab = useCallback((tabId: string, preferredPaneId?: string | null) => {
293
+ const targetPaneId = (() => {
294
+ const target = preferredPaneId ? findPaneInstance(state.config.layout, preferredPaneId) : null;
295
+ if (target?.paneId === "ticker-detail") return target.instanceId;
296
+ const focused = state.focusedPaneId ? findPaneInstance(state.config.layout, state.focusedPaneId) : null;
297
+ if (focused?.paneId === "ticker-detail") return focused.instanceId;
298
+ const sourcePaneId = resolveCollectionSourcePaneId(preferredPaneId);
299
+ if (!sourcePaneId) return null;
300
+ const ensured = ensureInspectorPane(sourcePaneId);
301
+ if (!ensured) return null;
302
+ if (ensured.layout !== state.config.layout) {
303
+ dispatch({ type: "PUSH_LAYOUT_HISTORY" });
304
+ const layouts = state.config.layouts.map((savedLayout, index) => (
305
+ index === state.config.activeLayoutIndex ? { ...savedLayout, layout: ensured.layout } : savedLayout
306
+ ));
307
+ dispatch({ type: "UPDATE_LAYOUT", layout: ensured.layout });
308
+ saveConfig({ ...state.config, layout: ensured.layout, layouts }).catch(() => {});
309
+ }
310
+ return ensured.instance.instanceId;
311
+ })();
312
+ if (!targetPaneId) return;
313
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: targetPaneId, patch: { activeTabId: tabId } });
314
+ dispatch({ type: "FOCUS_PANE", paneId: targetPaneId });
315
+ }, [
316
+ dispatch,
317
+ ensureInspectorPane,
318
+ resolveCollectionSourcePaneId,
319
+ state.config,
320
+ state.config.activeLayoutIndex,
321
+ state.config.layout,
322
+ state.focusedPaneId,
323
+ ]);
324
+
325
+ const buildPaneBinding = useCallback((paneType: string, preferredPaneId?: string | null): PaneBinding | null => {
326
+ if (paneType === "ticker-detail") {
327
+ const sourceInstanceId = resolveCollectionSourcePaneId(preferredPaneId);
328
+ return sourceInstanceId ? { kind: "follow", sourceInstanceId } : null;
329
+ }
330
+ if (isTickerPaneId(paneType)) {
331
+ const sourceInstanceId = resolveTickerContextSourcePaneId(preferredPaneId);
332
+ return sourceInstanceId ? { kind: "follow", sourceInstanceId } : null;
333
+ }
334
+ return { kind: "none" };
335
+ }, [resolveCollectionSourcePaneId, resolveTickerContextSourcePaneId]);
336
+
337
+ const buildPaneInstance = useCallback((paneType: string, options?: {
338
+ title?: string;
339
+ binding?: PaneBinding;
340
+ params?: Record<string, string>;
341
+ settings?: Record<string, unknown>;
342
+ instanceId?: string;
343
+ }): PaneInstanceConfig | null => {
344
+ if (paneType === "portfolio-list") {
345
+ const collectionId = options?.params?.collectionId
346
+ ?? getFocusedCollectionId(state)
347
+ ?? state.config.portfolios[0]?.id
348
+ ?? state.config.watchlists[0]?.id
349
+ ?? "";
350
+ return createPaneInstance(paneType, {
351
+ instanceId: options?.instanceId,
352
+ title: options?.title,
353
+ binding: options?.binding ?? { kind: "none" },
354
+ params: { collectionId },
355
+ settings: options?.settings,
356
+ });
357
+ }
358
+ const binding = options?.binding ?? buildPaneBinding(paneType);
359
+ if (isTickerPaneId(paneType) && !binding) return null;
360
+ return createPaneInstance(paneType, {
361
+ instanceId: options?.instanceId,
362
+ title: options?.title,
363
+ binding: binding ?? { kind: "none" },
364
+ params: options?.params,
365
+ settings: options?.settings,
366
+ });
367
+ }, [buildPaneBinding, state]);
368
+
369
+ const performRefreshTicker = useCallback(async (symbol: string, exchange = "", tickerOverride?: TickerRecord | null) => {
370
+ if (refreshInFlight.has(symbol)) return;
371
+ refreshInFlight.add(symbol);
372
+ dispatch({ type: "SET_REFRESHING", symbol, refreshing: true });
373
+ try {
374
+ const ticker = tickerOverride ?? state.tickers.get(symbol) ?? null;
375
+ const instrument = instrumentFromTicker(ticker, symbol);
376
+ if (!instrument) return;
377
+ const entry = await marketData.loadSnapshot(instrument, { forceRefresh: true });
378
+ const data = entry.data ?? entry.lastGoodData;
379
+ if (data) {
380
+ pluginRegistry.events.emit("ticker:refreshed", { symbol, financials: data });
381
+ }
382
+
383
+ const currency = data?.quote?.currency;
384
+ if (currency) {
385
+ void marketData.loadFxRate(currency).catch(() => {});
386
+ }
387
+ const base = state.config.baseCurrency;
388
+ void marketData.loadFxRate(base).catch(() => {});
389
+ } catch {
390
+ // Silently fail - will show "—" for missing data
391
+ } finally {
392
+ refreshInFlight.delete(symbol);
393
+ dispatch({ type: "SET_REFRESHING", symbol, refreshing: false });
394
+ }
395
+ }, [dispatch, marketData, pluginRegistry.events, state.config.baseCurrency, state.tickers]);
396
+
397
+ const performRefreshQuote = useCallback(async (symbol: string, exchange = "", tickerOverride?: TickerRecord | null) => {
398
+ if (refreshInFlight.has(symbol) || quoteRefreshInFlight.has(symbol)) return;
399
+ quoteRefreshInFlight.add(symbol);
400
+ try {
401
+ const ticker = tickerOverride ?? state.tickers.get(symbol) ?? null;
402
+ const instrument = instrumentFromTicker(ticker, symbol);
403
+ if (!instrument) return;
404
+ const entry = await marketData.loadQuote(instrument, { forceRefresh: true });
405
+ const quote = entry.data ?? entry.lastGoodData;
406
+ if (!quote) return;
407
+
408
+ const currency = quote.currency;
409
+ if (currency) {
410
+ void marketData.loadFxRate(currency).catch(() => {});
411
+ }
412
+ const base = state.config.baseCurrency;
413
+ void marketData.loadFxRate(base).catch(() => {});
414
+ } catch {
415
+ // Silently fail - the list can fall back to stale cache or Yahoo
416
+ } finally {
417
+ quoteRefreshInFlight.delete(symbol);
418
+ }
419
+ }, [marketData, state.config.baseCurrency, state.tickers]);
420
+
421
+ const refreshQueueRef = useRef<{
422
+ queue: TickerRefreshQueue;
423
+ }>({
424
+ queue: new TickerRefreshQueue(3),
425
+ });
426
+ const pendingRefreshesRef = useRef<{
427
+ financials: Set<string>;
428
+ quotes: Set<string>;
429
+ }>({
430
+ financials: new Set<string>(),
431
+ quotes: new Set<string>(),
432
+ });
433
+
434
+ useEffect(() => {
435
+ refreshQueueRef.current.queue.setPaused(!appActive);
436
+ chatController.setAppActive(appActive);
437
+ appLog.info("app activity propagated", { active: appActive });
438
+ }, [appActive]);
439
+
440
+ const refreshTicker = useCallback((symbol: string, exchange = "", tickerOverride?: TickerRecord | null, priority = 2) => {
441
+ if (refreshInFlight.has(symbol) || pendingRefreshesRef.current.financials.has(symbol)) return;
442
+ pendingRefreshesRef.current.financials.add(symbol);
443
+ refreshQueueRef.current.queue.enqueue({
444
+ key: `financials:${symbol}`,
445
+ priority,
446
+ run: async () => {
447
+ try {
448
+ await performRefreshTicker(symbol, exchange, tickerOverride ?? null);
449
+ } finally {
450
+ pendingRefreshesRef.current.financials.delete(symbol);
451
+ }
452
+ },
453
+ });
454
+ }, [performRefreshTicker]);
455
+
456
+ const refreshQuote = useCallback((symbol: string, exchange = "", tickerOverride?: TickerRecord | null, priority = 2) => {
457
+ if (
458
+ refreshInFlight.has(symbol)
459
+ || quoteRefreshInFlight.has(symbol)
460
+ || pendingRefreshesRef.current.financials.has(symbol)
461
+ || pendingRefreshesRef.current.quotes.has(symbol)
462
+ ) {
463
+ return;
464
+ }
465
+ pendingRefreshesRef.current.quotes.add(symbol);
466
+ refreshQueueRef.current.queue.enqueue({
467
+ key: `quote:${symbol}`,
468
+ priority,
469
+ run: async () => {
470
+ try {
471
+ if (pendingRefreshesRef.current.financials.has(symbol) || refreshInFlight.has(symbol)) return;
472
+ await performRefreshQuote(symbol, exchange, tickerOverride ?? null);
473
+ } finally {
474
+ pendingRefreshesRef.current.quotes.delete(symbol);
475
+ }
476
+ },
477
+ });
478
+ }, [performRefreshQuote]);
479
+
480
+ const primeCachedFinancials = useCallback((entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => {
481
+ const primeEntries = entries.flatMap(({ ticker, financials }) => {
482
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
483
+ return instrument ? [{ instrument, financials }] : [];
484
+ });
485
+ if (primeEntries.length === 0) return;
486
+ marketData.primeCachedFinancials(primeEntries);
487
+ }, [marketData]);
488
+
489
+ // Import positions from a single broker instance
490
+ const importBrokerPositions = useCallback(async (
491
+ instanceId: string,
492
+ tickerMap?: Map<string, TickerRecord>,
493
+ options?: { refreshImportedTickers?: boolean },
494
+ ) => {
495
+ const result = await syncBrokerInstance({
496
+ config: state.config,
497
+ instanceId,
498
+ brokers: pluginRegistry.brokers,
499
+ tickerRepository,
500
+ existingTickers: tickerMap ?? new Map(state.tickers),
501
+ resources: pluginRegistry.persistence.resources,
502
+ });
503
+
504
+ dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts: result.brokerAccounts });
505
+
506
+ if (result.config !== state.config) {
507
+ dispatch({ type: "SET_CONFIG", config: result.config });
508
+ await saveConfig(result.config);
509
+ pluginRegistry.events.emit("config:changed", { config: result.config });
510
+ }
511
+
512
+ for (const ticker of result.addedTickers) {
513
+ pluginRegistry.events.emit("ticker:added", { symbol: ticker.metadata.ticker, ticker });
514
+ }
515
+ for (const ticker of [...result.addedTickers, ...result.updatedTickers]) {
516
+ dispatch({ type: "UPDATE_TICKER", ticker: { ...ticker } });
517
+ }
518
+
519
+ for (const position of result.positions) {
520
+ // Skip Yahoo Finance for options — IBKR symbols aren't resolvable there.
521
+ // Position data (markPrice, marketValue, unrealizedPnl) is used directly.
522
+ if (options?.refreshImportedTickers !== false && position.assetCategory !== "OPT") {
523
+ refreshQuote(position.ticker, position.exchange, undefined, 1);
524
+ }
525
+ }
526
+
527
+ return result;
528
+ }, [dispatch, pluginRegistry.brokers, pluginRegistry.events, pluginRegistry.persistence.resources, refreshQuote, state.config, state.tickers, tickerRepository]);
529
+
530
+ // Auto-import positions from all configured broker instances
531
+ const autoImportBrokerPositions = useCallback(async (tickerMap: Map<string, TickerRecord>) => {
532
+ let nextTickerMap = tickerMap;
533
+ for (const instance of state.config.brokerInstances) {
534
+ if (instance.enabled === false) continue;
535
+ try {
536
+ const result = await importBrokerPositions(instance.id, nextTickerMap, { refreshImportedTickers: false });
537
+ if (result) {
538
+ nextTickerMap = result.tickers;
539
+ }
540
+ } catch {
541
+ // Silently fail — broker import is best-effort on startup
542
+ }
543
+ }
544
+ }, [state.config.brokerInstances, importBrokerPositions]);
545
+
546
+ // Check for updates on mount
547
+ useEffect(() => {
548
+ checkForUpdate(VERSION).then((release) => {
549
+ if (release) dispatch({ type: "SET_UPDATE_AVAILABLE", release });
550
+ });
551
+ }, []);
552
+
553
+ // Load tickers on mount
554
+ useEffect(() => {
555
+ if (state.initialized || (globalThis as any).__gloomInitStarted) return;
556
+ (globalThis as any).__gloomInitStarted = true;
557
+ (async () => {
558
+ try {
559
+ let persistedBrokerAccounts: Record<string, BrokerAccount[]> = {};
560
+ try {
561
+ persistedBrokerAccounts = loadPersistedIbkrAccountMap(
562
+ pluginRegistry.persistence.resources,
563
+ state.config.brokerInstances,
564
+ );
565
+ } catch {}
566
+ await initializeAppState({
567
+ config: state.config,
568
+ tickerRepository,
569
+ dataProvider,
570
+ sessionSnapshot,
571
+ dispatch,
572
+ primeCachedFinancials,
573
+ refreshTicker,
574
+ refreshQuote,
575
+ autoImportBrokerPositions,
576
+ persistedBrokerAccounts,
577
+ });
578
+ } catch (err) {
579
+ // Will show empty state
580
+ }
581
+ })();
582
+ }, [autoImportBrokerPositions, dataProvider, dispatch, primeCachedFinancials, tickerRepository, refreshQuote, refreshTicker, sessionSnapshot, state.config, state.initialized]);
583
+
584
+ const focusedTickerSymbol = getFocusedTickerSymbol(state);
585
+
586
+ useEffect(() => {
587
+ if (!focusedTickerSymbol) return;
588
+ const ticker = state.tickers.get(focusedTickerSymbol);
589
+ if (!ticker) return;
590
+ marketData.prefetchTicker(instrumentFromTicker(ticker, ticker.metadata.ticker));
591
+ }, [focusedTickerSymbol, marketData, state.tickers]);
592
+
593
+ // Wire up plugin registry data accessors
594
+ pluginRegistry.getTickerFn = (symbol) => state.tickers.get(symbol) ?? null;
595
+ pluginRegistry.getDataFn = (symbol) => {
596
+ const ticker = state.tickers.get(symbol) ?? null;
597
+ const instrument = instrumentFromTicker(ticker, symbol);
598
+ return instrument ? marketData.getTickerFinancialsSync(instrument) : null;
599
+ };
600
+ pluginRegistry.getConfigFn = () => state.config;
601
+ pluginRegistry.getPaneRuntimeStateFn = (paneId) => state.paneState[paneId] ?? null;
602
+ pluginRegistry.updatePaneRuntimeStateFn = (paneId, patch) => {
603
+ dispatch({ type: "UPDATE_PANE_STATE", paneId, patch });
604
+ };
605
+ pluginRegistry.getPluginConfigValueFn = (pluginId, key) => (
606
+ (state.config.pluginConfig[pluginId]?.[key] as any) ?? null
607
+ );
608
+ pluginRegistry.setPluginConfigValueFn = async (pluginId, key, value) => {
609
+ const nextConfig = {
610
+ ...state.config,
611
+ pluginConfig: {
612
+ ...state.config.pluginConfig,
613
+ [pluginId]: {
614
+ ...(state.config.pluginConfig[pluginId] ?? {}),
615
+ [key]: value,
616
+ },
617
+ },
618
+ };
619
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
620
+ await saveConfig(nextConfig);
621
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
622
+ };
623
+ pluginRegistry.deletePluginConfigValueFn = async (pluginId, key) => {
624
+ const currentPluginConfig = state.config.pluginConfig[pluginId];
625
+ if (!currentPluginConfig || !(key in currentPluginConfig)) return;
626
+
627
+ const nextPluginConfig = { ...currentPluginConfig };
628
+ delete nextPluginConfig[key];
629
+
630
+ const nextAllPluginConfig = { ...state.config.pluginConfig };
631
+ if (Object.keys(nextPluginConfig).length === 0) {
632
+ delete nextAllPluginConfig[pluginId];
633
+ } else {
634
+ nextAllPluginConfig[pluginId] = nextPluginConfig;
635
+ }
636
+
637
+ const nextConfig = {
638
+ ...state.config,
639
+ pluginConfig: nextAllPluginConfig,
640
+ };
641
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
642
+ await saveConfig(nextConfig);
643
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
644
+ };
645
+ if (dataProvider instanceof ProviderRouter) {
646
+ dataProvider.setConfigAccessor(() => state.config);
647
+ }
648
+ pluginRegistry.createBrokerInstanceFn = async (brokerType, label, values) => {
649
+ const instanceId = createBrokerInstanceId(
650
+ brokerType,
651
+ label,
652
+ state.config.brokerInstances.map((instance) => instance.id),
653
+ );
654
+ const instance: BrokerInstanceConfig = {
655
+ id: instanceId,
656
+ brokerType,
657
+ label,
658
+ connectionMode: typeof values.connectionMode === "string" ? values.connectionMode : undefined,
659
+ config: values,
660
+ enabled: true,
661
+ };
662
+ const nextConfig = {
663
+ ...state.config,
664
+ brokerInstances: [...state.config.brokerInstances, instance],
665
+ };
666
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
667
+ await saveConfig(nextConfig);
668
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
669
+ return instance;
670
+ };
671
+ pluginRegistry.updateBrokerInstanceFn = async (instanceId, values) => {
672
+ const currentInstance = state.config.brokerInstances.find((instance) => instance.id === instanceId);
673
+ const nextInstances = state.config.brokerInstances.map((instance) =>
674
+ instance.id === instanceId
675
+ ? {
676
+ ...instance,
677
+ connectionMode: typeof values.connectionMode === "string" ? values.connectionMode : instance.connectionMode,
678
+ config: { ...instance.config, ...values },
679
+ }
680
+ : instance,
681
+ );
682
+ const nextInstance = nextInstances.find((instance) => instance.id === instanceId);
683
+ const shouldClearIbkrAccounts = currentInstance?.brokerType === "ibkr"
684
+ && nextInstance?.brokerType === "ibkr"
685
+ && getIbkrConfigIdentity(currentInstance.config) !== getIbkrConfigIdentity(nextInstance.config);
686
+ if (shouldClearIbkrAccounts) {
687
+ clearPersistedIbkrAccounts(pluginRegistry.persistence.resources, instanceId);
688
+ }
689
+ const nextConfig = {
690
+ ...state.config,
691
+ brokerInstances: nextInstances,
692
+ };
693
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
694
+ await saveConfig(nextConfig);
695
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
696
+ };
697
+ pluginRegistry.syncBrokerInstanceFn = async (instanceId) => {
698
+ await importBrokerPositions(instanceId);
699
+ };
700
+ pluginRegistry.removeBrokerInstanceFn = async (instanceId) => {
701
+ const instance = getBrokerInstance(state.config.brokerInstances, instanceId);
702
+ if (!instance) return;
703
+
704
+ clearPersistedIbkrAccounts(pluginRegistry.persistence.resources, instanceId);
705
+
706
+ const broker = pluginRegistry.brokers.get(instance.brokerType);
707
+ await broker?.disconnect?.(instance).catch(() => {});
708
+
709
+ const removedPortfolioIds = new Set(
710
+ state.config.portfolios
711
+ .filter((portfolio) => portfolio.brokerInstanceId === instanceId)
712
+ .map((portfolio) => portfolio.id),
713
+ );
714
+
715
+ const nextPortfolios = state.config.portfolios.filter((portfolio) => !removedPortfolioIds.has(portfolio.id));
716
+ const nextTickers = new Map(state.tickers);
717
+
718
+ for (const ticker of state.tickers.values()) {
719
+ const nextPositions = ticker.metadata.positions.filter((position) => position.brokerInstanceId !== instanceId);
720
+ const nextPortfolioRefs = ticker.metadata.portfolios.filter((portfolioId) => !removedPortfolioIds.has(portfolioId));
721
+ const nextBrokerContracts = (ticker.metadata.broker_contracts ?? []).filter((contract) => contract.brokerInstanceId !== instanceId);
722
+
723
+ const nextTicker: TickerRecord = {
724
+ ...ticker,
725
+ metadata: {
726
+ ...ticker.metadata,
727
+ positions: nextPositions,
728
+ portfolios: nextPortfolioRefs,
729
+ broker_contracts: nextBrokerContracts,
730
+ },
731
+ };
732
+
733
+ const shouldDeleteTicker =
734
+ nextPositions.length === 0
735
+ && nextPortfolioRefs.length === 0
736
+ && nextTicker.metadata.watchlists.length === 0
737
+ && nextBrokerContracts.length === 0
738
+ && nextTicker.metadata.tags.length === 0
739
+ && Object.keys(nextTicker.metadata.custom).length === 0;
740
+
741
+ if (shouldDeleteTicker) {
742
+ nextTickers.delete(ticker.metadata.ticker);
743
+ await tickerRepository.deleteTicker(ticker.metadata.ticker);
744
+ dispatch({ type: "REMOVE_TICKER", symbol: ticker.metadata.ticker });
745
+ pluginRegistry.events.emit("ticker:removed", { symbol: ticker.metadata.ticker });
746
+ } else {
747
+ await tickerRepository.saveTicker(nextTicker);
748
+ nextTickers.set(nextTicker.metadata.ticker, nextTicker);
749
+ dispatch({ type: "UPDATE_TICKER", ticker: nextTicker });
750
+ }
751
+ }
752
+
753
+ const nextConfig = {
754
+ ...state.config,
755
+ brokerInstances: state.config.brokerInstances.filter((entry) => entry.id !== instanceId),
756
+ portfolios: nextPortfolios,
757
+ };
758
+
759
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
760
+ dispatch({ type: "SET_TICKERS", tickers: nextTickers });
761
+ await saveConfig(nextConfig);
762
+ pluginRegistry.events.emit("config:changed", { config: nextConfig });
763
+ };
764
+
765
+ // Wire up navigation functions
766
+ pluginRegistry.selectTickerFn = (symbol, paneId) => selectTickerInPane(symbol, paneId);
767
+ pluginRegistry.switchPanelFn = (panel) => dispatch({ type: "SET_ACTIVE_PANEL", panel });
768
+ pluginRegistry.switchTabFn = (tabId, paneId) => switchDetailTab(tabId, paneId);
769
+ pluginRegistry.openCommandBarFn = (query) => dispatch({ type: "SET_COMMAND_BAR", open: true, query });
770
+ const persistLayout = (layout: LayoutConfig, options?: { pushHistory?: boolean }) => {
771
+ const currentState = stateRef.current;
772
+ const normalizedLayout = normalizePaneLayout(layout);
773
+ const layouts = currentState.config.layouts.map((savedLayout, index) => (
774
+ index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout: normalizedLayout } : savedLayout
775
+ ));
776
+ if (options?.pushHistory !== false) {
777
+ dispatch({ type: "PUSH_LAYOUT_HISTORY" });
778
+ }
779
+ dispatch({ type: "UPDATE_LAYOUT", layout: normalizedLayout });
780
+ saveConfig({ ...currentState.config, layout: normalizedLayout, layouts }).catch(() => {});
781
+ };
782
+ const resolvePanelForPane = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): "left" | "right" => {
783
+ const instanceId = resolvePaneTarget(paneId, layout);
784
+ if (!instanceId) return "right";
785
+ const instance = findPaneInstance(layout, instanceId);
786
+ const paneDef = instance ? pluginRegistry.panes.get(instance.paneId) : pluginRegistry.panes.get(paneId);
787
+ const floating = layout.floating.find((entry) => entry.instanceId === instanceId);
788
+ if (floating) {
789
+ return paneDef?.defaultPosition ?? "right";
790
+ }
791
+
792
+ const rect = getLeafRect(layout, instanceId, PANEL_RESOLUTION_BOUNDS);
793
+ if (!rect) {
794
+ return paneDef?.defaultPosition ?? "right";
795
+ }
796
+
797
+ const midpoint = PANEL_RESOLUTION_BOUNDS.width / 2;
798
+ return rect.x + (rect.width / 2) <= midpoint ? "left" : "right";
799
+ }, [pluginRegistry.panes, resolvePaneTarget, state.config.layout]);
800
+ const activatePane = useCallback((paneId: string, layout: LayoutConfig = state.config.layout) => {
801
+ dispatch({ type: "SET_ACTIVE_PANEL", panel: resolvePanelForPane(paneId, layout) });
802
+ dispatch({ type: "FOCUS_PANE", paneId });
803
+ }, [dispatch, resolvePanelForPane, state.config.layout]);
804
+ const placePaneInstance = useCallback((
805
+ instance: PaneInstanceConfig,
806
+ paneDef: NonNullable<ReturnType<typeof pluginRegistry.panes.get>>,
807
+ options?: PaneTemplateInstanceConfig,
808
+ ) => {
809
+ const { width, height } = pluginRegistry.getTermSizeFn();
810
+ const relativeTo = options?.relativeToPaneId
811
+ ? resolvePaneTarget(options.relativeToPaneId)
812
+ : (state.focusedPaneId && isPaneInLayout(state.config.layout, state.focusedPaneId) ? state.focusedPaneId : null);
813
+ const relativePosition = options?.relativePosition ?? "right";
814
+ let nextLayout = state.config.layout;
815
+ const selectEdgeAnchor = (edge: "left" | "right") => {
816
+ const leaves = getDockLeafLayouts(nextLayout, PANEL_RESOLUTION_BOUNDS);
817
+ if (leaves.length === 0) return null;
818
+ const edgeCoordinate = edge === "left"
819
+ ? Math.min(...leaves.map((leaf) => leaf.rect.x))
820
+ : Math.max(...leaves.map((leaf) => leaf.rect.x + leaf.rect.width));
821
+ return [...leaves]
822
+ .filter((leaf) => (
823
+ edge === "left"
824
+ ? leaf.rect.x === edgeCoordinate
825
+ : leaf.rect.x + leaf.rect.width === edgeCoordinate
826
+ ))
827
+ .sort((a, b) => (b.rect.y + b.rect.height) - (a.rect.y + a.rect.height))
828
+ [0]?.instanceId ?? null;
829
+ };
830
+ const dockedPaneIds = getDockedPaneIds(nextLayout);
831
+
832
+ if (options?.placement === "floating" || (options?.placement !== "docked" && paneDef.defaultMode === "floating")) {
833
+ nextLayout = addPaneFloating(nextLayout, instance, width, height, paneDef);
834
+ } else if (relativeTo && findDockLeaf(nextLayout, relativeTo)) {
835
+ nextLayout = addPaneToLayout(nextLayout, instance, { relativeTo, position: relativePosition });
836
+ } else if (dockedPaneIds.length === 0) {
837
+ nextLayout = addPaneToLayout(nextLayout, instance, { relativeTo: instance.instanceId, position: "right" });
838
+ } else if (paneDef.defaultPosition === "left") {
839
+ const leftAnchor = selectEdgeAnchor("left");
840
+ nextLayout = leftAnchor
841
+ ? addPaneToLayout(nextLayout, instance, { relativeTo: leftAnchor, position: "below" })
842
+ : addPaneToLayout(nextLayout, instance, { relativeTo: dockedPaneIds[0]!, position: "left" });
843
+ } else {
844
+ const rightAnchor = selectEdgeAnchor("right");
845
+ nextLayout = rightAnchor
846
+ ? addPaneToLayout(nextLayout, instance, { relativeTo: rightAnchor, position: "below" })
847
+ : addPaneToLayout(nextLayout, instance, { relativeTo: dockedPaneIds[dockedPaneIds.length - 1]!, position: "right" });
848
+ }
849
+
850
+ persistLayout(nextLayout);
851
+ activatePane(instance.instanceId, nextLayout);
852
+ }, [
853
+ activatePane,
854
+ dispatch,
855
+ pluginRegistry,
856
+ persistLayout,
857
+ resolvePaneTarget,
858
+ state.config.layout,
859
+ state.focusedPaneId,
860
+ ]);
861
+ const showPane = (paneId: string) => {
862
+ const paneDef = pluginRegistry.panes.get(paneId);
863
+ if (!paneDef) return;
864
+
865
+ if (paneId === "ticker-detail") {
866
+ const sourcePaneId = resolveCollectionSourcePaneId();
867
+ if (!sourcePaneId) {
868
+ pluginRegistry.showToastFn("Open a collection pane first to inspect a ticker.");
869
+ return;
870
+ }
871
+ const ensured = ensureInspectorPane(sourcePaneId);
872
+ if (!ensured) return;
873
+ if (ensured.layout !== state.config.layout) {
874
+ persistLayout(ensured.layout);
875
+ }
876
+ activatePane(ensured.instance.instanceId, ensured.layout);
877
+ return;
878
+ }
879
+
880
+ const existingInstanceId = resolvePaneTarget(paneId);
881
+ if (existingInstanceId && isPaneInLayout(state.config.layout, existingInstanceId)) {
882
+ pluginRegistry.focusPaneFn(existingInstanceId);
883
+ return;
884
+ }
885
+
886
+ const instance = existingInstanceId
887
+ ? findPaneInstance(state.config.layout, existingInstanceId)
888
+ : buildPaneInstance(paneId);
889
+ if (!instance) {
890
+ if (isTickerPaneId(paneId)) {
891
+ pluginRegistry.showToastFn("Open a ticker or collection context first.");
892
+ }
893
+ return;
894
+ }
895
+ placePaneInstance(instance, paneDef, { placement: "default" });
896
+ };
897
+ const createPaneFromTemplate = useCallback(async (templateId: string, options?: PaneTemplateCreateOptions) => {
898
+ const template = pluginRegistry.paneTemplates.get(templateId);
899
+ if (!template) return;
900
+
901
+ let resolvedOptions = options;
902
+ const shouldRunDialogWizard = !!template.wizard
903
+ && template.wizard.length > 0
904
+ && !options?.values
905
+ && (!options?.arg || template.wizard.some((step) => step.type === "textarea"));
906
+ if (shouldRunDialogWizard && template.wizard) {
907
+ const wizardSteps = options?.arg && template.shortcut?.argPlaceholder
908
+ ? template.wizard.map((step) => (
909
+ step.key === template.shortcut?.argPlaceholder
910
+ ? { ...step, defaultValue: options.arg }
911
+ : step
912
+ ))
913
+ : template.wizard;
914
+ const values = await runPaneTemplateWizard(wizardSteps);
915
+ if (!values) return;
916
+ resolvedOptions = {
917
+ ...options,
918
+ values,
919
+ arg: template.shortcut?.argPlaceholder ? values[template.shortcut.argPlaceholder] : options?.arg,
920
+ };
921
+ }
922
+
923
+ try {
924
+ await createPaneTemplateOrThrow(templateId, resolvedOptions, {
925
+ dataProvider,
926
+ tickerRepository,
927
+ pluginRegistry,
928
+ dispatch,
929
+ getState: () => stateRef.current,
930
+ buildPaneInstance,
931
+ placePaneInstance,
932
+ });
933
+ } catch (error) {
934
+ pluginRegistry.showToastFn(
935
+ error instanceof Error ? error.message : `Could not create ${template.label.toLowerCase()}.`,
936
+ { type: "info" },
937
+ );
938
+ }
939
+ }, [
940
+ buildPaneInstance,
941
+ dataProvider,
942
+ dispatch,
943
+ placePaneInstance,
944
+ pluginRegistry,
945
+ runPaneTemplateWizard,
946
+ tickerRepository,
947
+ ]);
948
+
949
+ const openPaneSettings = useCallback(async (paneId?: string) => {
950
+ const targetPaneId = paneId
951
+ ? resolvePaneTarget(paneId)
952
+ : stateRef.current.focusedPaneId;
953
+ if (!targetPaneId || !pluginRegistry.hasPaneSettings(targetPaneId)) return;
954
+
955
+ let shouldPushHistory = true;
956
+ const applyFieldValue = async (targetId: string, field: PaneSettingField, value: unknown) => {
957
+ await applyPaneSettingFieldValueShared(targetId, field, value, {
958
+ dataProvider,
959
+ tickerRepository,
960
+ pluginRegistry,
961
+ dispatch,
962
+ getState: () => stateRef.current,
963
+ persistLayout,
964
+ }, { pushHistory: shouldPushHistory });
965
+ shouldPushHistory = false;
966
+ };
967
+
968
+ await dialog.alert({
969
+ content: (ctx) => (
970
+ <PaneSettingsDialogContent
971
+ {...ctx}
972
+ paneId={targetPaneId}
973
+ pluginRegistry={pluginRegistry}
974
+ applyFieldValue={applyFieldValue}
975
+ />
976
+ ),
977
+ });
978
+ }, [dataProvider, dialog, dispatch, persistLayout, pluginRegistry, resolvePaneTarget, tickerRepository]);
979
+
980
+ pluginRegistry.getLayoutFn = () => state.config.layout;
981
+ pluginRegistry.updateLayoutFn = (layout) => persistLayout(layout);
982
+ pluginRegistry.openPaneSettingsFn = (paneId) => { void openPaneSettings(paneId); };
983
+ pluginRegistry.showPaneFn = (paneId) => showPane(paneId);
984
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
985
+ await createPaneTemplateOrThrow(templateId, options, {
986
+ dataProvider,
987
+ tickerRepository,
988
+ pluginRegistry,
989
+ dispatch,
990
+ getState: () => stateRef.current,
991
+ buildPaneInstance,
992
+ placePaneInstance,
993
+ });
994
+ };
995
+ pluginRegistry.createPaneFromTemplateFn = (templateId, options) => { void createPaneFromTemplate(templateId, options); };
996
+ pluginRegistry.applyPaneSettingValueFn = async (paneId, field, value) => {
997
+ await applyPaneSettingFieldValueShared(paneId, field, value, {
998
+ dataProvider,
999
+ tickerRepository,
1000
+ pluginRegistry,
1001
+ dispatch,
1002
+ getState: () => stateRef.current,
1003
+ persistLayout,
1004
+ });
1005
+ };
1006
+ pluginRegistry.hidePaneFn = (paneId) => {
1007
+ const instanceId = resolvePaneTarget(paneId);
1008
+ if (!instanceId || !isPaneInLayout(state.config.layout, instanceId)) return;
1009
+ persistLayout(removePane(state.config.layout, instanceId));
1010
+ };
1011
+ pluginRegistry.focusPaneFn = (paneId) => {
1012
+ const instanceId = resolvePaneTarget(paneId);
1013
+ if (!instanceId || !isPaneInLayout(state.config.layout, instanceId)) {
1014
+ showPane(paneId);
1015
+ return;
1016
+ }
1017
+
1018
+ const layout = state.config.layout.floating.some((entry) => entry.instanceId === instanceId)
1019
+ ? bringToFront(state.config.layout, instanceId)
1020
+ : state.config.layout;
1021
+
1022
+ if (layout !== state.config.layout) {
1023
+ persistLayout(layout, { pushHistory: false });
1024
+ }
1025
+ activatePane(instanceId, layout);
1026
+ };
1027
+ pluginRegistry.pinTickerFn = (symbol, options) => {
1028
+ const paneType = options?.paneType ?? "ticker-detail";
1029
+ const paneDef = pluginRegistry.panes.get(paneType);
1030
+ if (!paneDef) return;
1031
+ const instance = buildPaneInstance(paneType, {
1032
+ title: symbol,
1033
+ binding: { kind: "fixed", symbol },
1034
+ });
1035
+ if (!instance) return;
1036
+ const { width, height } = pluginRegistry.getTermSizeFn();
1037
+ const shouldFloat = options?.floating ?? true;
1038
+ const nextLayout = shouldFloat
1039
+ ? addPaneFloating(state.config.layout, instance, width, height, paneDef)
1040
+ : addPaneToLayout(
1041
+ state.config.layout,
1042
+ instance,
1043
+ {
1044
+ relativeTo: state.focusedPaneId && isPaneInLayout(state.config.layout, state.focusedPaneId)
1045
+ ? state.focusedPaneId
1046
+ : (getDockedPaneIds(state.config.layout).at(-1) ?? instance.instanceId),
1047
+ position: "right",
1048
+ },
1049
+ );
1050
+ persistLayout(nextLayout);
1051
+ activatePane(instance.instanceId, nextLayout);
1052
+ };
1053
+
1054
+ setLayoutManagerDispatch(dispatch, () => ({
1055
+ layout: state.config.layout,
1056
+ termWidth: pluginRegistry.getTermSizeFn().width,
1057
+ termHeight: pluginRegistry.getTermSizeFn().height,
1058
+ focusedPaneId: state.focusedPaneId,
1059
+ }));
1060
+
1061
+ // Wire up toast
1062
+ pluginRegistry.showToastFn = (message, options) => {
1063
+ const type = options?.type ?? "info";
1064
+ const duration = options?.duration;
1065
+ if (type === "success") toast.success(message, { duration });
1066
+ else if (type === "error") toast.error(message, { duration });
1067
+ else toast.info(message, { duration });
1068
+ };
1069
+
1070
+ // Persist layout changes (switching, saving, deleting, renaming layouts)
1071
+ const prevLayouts = useRef(state.config.layouts);
1072
+ useEffect(() => {
1073
+ if (state.config.layouts !== prevLayouts.current) {
1074
+ prevLayouts.current = state.config.layouts;
1075
+ saveConfig(state.config).catch(() => {});
1076
+ }
1077
+ }, [state.config.layouts, state.config]);
1078
+
1079
+ // Emit ticker:selected events based on focused pane context.
1080
+ const prevSelectedRef = useRef(focusedTickerSymbol);
1081
+ useEffect(() => {
1082
+ if (focusedTickerSymbol !== prevSelectedRef.current) {
1083
+ pluginRegistry.events.emit("ticker:selected", {
1084
+ symbol: focusedTickerSymbol,
1085
+ previous: prevSelectedRef.current,
1086
+ });
1087
+ prevSelectedRef.current = focusedTickerSymbol;
1088
+ }
1089
+ }, [focusedTickerSymbol]);
1090
+
1091
+ // Check if a dialog is currently open (wizard, confirm, etc.)
1092
+ const dialogOpen = useDialogState((s) => s.isOpen);
1093
+ const focusedPane = state.focusedPaneId ? findPaneInstance(state.config.layout, state.focusedPaneId) : null;
1094
+ const focusedDetailTab = state.focusedPaneId ? state.paneState[state.focusedPaneId]?.activeTabId : undefined;
1095
+
1096
+ // Global keyboard shortcuts
1097
+ useKeyboard((event) => {
1098
+ // Skip all global shortcuts when a dialog is open
1099
+ if (dialogOpen) return;
1100
+
1101
+ // Ctrl+P: toggle command bar (backtick handled in command-bar itself for close)
1102
+ if (event.name === "p" && event.ctrl) {
1103
+ dispatch({ type: "TOGGLE_COMMAND_BAR" });
1104
+ return;
1105
+ }
1106
+ // Backtick opens command bar (close is handled in command-bar.tsx)
1107
+ if (event.name === "`" && !state.commandBarOpen) {
1108
+ dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
1109
+ return;
1110
+ }
1111
+ // Ctrl+1-9: switch layouts (works even when input is captured)
1112
+ if (/^[1-9]$/.test(event.name ?? "") && event.ctrl && (state.config.layouts ?? []).length > 1) {
1113
+ const idx = parseInt(event.name!, 10) - 1;
1114
+ const layouts = state.config.layouts ?? [];
1115
+ if (idx < layouts.length && idx !== state.config.activeLayoutIndex) {
1116
+ dispatch({ type: "SWITCH_LAYOUT", index: idx });
1117
+ }
1118
+ return;
1119
+ }
1120
+
1121
+ // Don't process main shortcuts when overlays are open or input is captured
1122
+ // (panes already get focused=false via shell.tsx)
1123
+ if (state.commandBarOpen || state.inputCaptured) return;
1124
+
1125
+ if (event.name === "tab") {
1126
+ // Build pane order from current layout for cycling
1127
+ const layout = state.config.layout;
1128
+ const paneOrder = [
1129
+ ...getDockedPaneIds(layout),
1130
+ ...layout.floating.map((entry) => entry.instanceId),
1131
+ ];
1132
+
1133
+ if (event.shift) {
1134
+ dispatch({ type: "FOCUS_PREV", paneOrder });
1135
+ } else {
1136
+ dispatch({ type: "FOCUS_NEXT", paneOrder });
1137
+ }
1138
+ } else if (event.name === "q" && !(focusedPane?.paneId === "ticker-detail" && focusedDetailTab === "financials")) {
1139
+ renderer.destroy();
1140
+ } else if (event.name === "r") {
1141
+ // Refresh focused ticker context.
1142
+ if (focusedTickerSymbol) {
1143
+ const ticker = state.tickers.get(focusedTickerSymbol);
1144
+ if (ticker) refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 0);
1145
+ }
1146
+ } else if (event.name === "R" || (event.name === "r" && event.shift)) {
1147
+ // Refresh all
1148
+ for (const t of state.tickers.values()) {
1149
+ refreshTicker(t.metadata.ticker, t.metadata.exchange, t, 1);
1150
+ }
1151
+ } else if (event.name === "a" && focusedTickerSymbol) {
1152
+ // Open ticker actions
1153
+ const actions = [...pluginRegistry.tickerActions.values()];
1154
+ const ticker = state.tickers.get(focusedTickerSymbol);
1155
+ if (actions.length > 0 && ticker) {
1156
+ dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
1157
+ }
1158
+ } else if (event.name === "u" && state.updateAvailable && !state.updateProgress && canSelfUpdate(state.updateAvailable)) {
1159
+ performUpdate(state.updateAvailable, (progress) => {
1160
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress });
1161
+ });
1162
+ } else {
1163
+ // Plugin keyboard shortcuts (built-ins take priority)
1164
+ const disabledPlugins = new Set(state.config.disabledPlugins || []);
1165
+ for (const shortcut of pluginRegistry.shortcuts.values()) {
1166
+ const ownerId = pluginRegistry.getShortcutPluginId(shortcut.id);
1167
+ if (ownerId && disabledPlugins.has(ownerId)) continue;
1168
+ if (shortcut.key === event.name
1169
+ && (shortcut.ctrl ?? false) === (event.ctrl ?? false)
1170
+ && (shortcut.shift ?? false) === (event.shift ?? false)) {
1171
+ shortcut.execute();
1172
+ break;
1173
+ }
1174
+ }
1175
+ }
1176
+ });
1177
+
1178
+ return (
1179
+ <box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1180
+ <Header />
1181
+ <Shell pluginRegistry={pluginRegistry} />
1182
+ <StatusBar />
1183
+ {state.commandBarOpen && (
1184
+ <CommandBar
1185
+ dataProvider={dataProvider}
1186
+ tickerRepository={tickerRepository}
1187
+ pluginRegistry={pluginRegistry}
1188
+ quitApp={() => renderer.destroy()}
1189
+ />
1190
+ )}
1191
+ <Toaster position="bottom-right" />
1192
+ </box>
1193
+ );
1194
+ }
1195
+
1196
+ interface AppProps {
1197
+ config: AppConfig;
1198
+ renderer: CliRenderer;
1199
+ externalPlugins?: import("./plugins/loader").LoadedExternalPlugin[];
1200
+ predictionLaunchIntent?: PredictionLaunchIntent | null;
1201
+ }
1202
+
1203
+ export function App({
1204
+ config: initialConfig,
1205
+ renderer,
1206
+ externalPlugins = [],
1207
+ predictionLaunchIntent = null,
1208
+ }: AppProps) {
1209
+ const applyPredictionLaunch = useCallback((configValue: AppConfig) => {
1210
+ if (!predictionLaunchIntent) return configValue;
1211
+ return applyPredictionLaunchIntentToConfig(configValue, predictionLaunchIntent, {
1212
+ width: Math.max(renderer.terminalWidth, 120),
1213
+ height: Math.max(renderer.terminalHeight, 40),
1214
+ }).config;
1215
+ }, [predictionLaunchIntent, renderer.terminalHeight, renderer.terminalWidth]);
1216
+
1217
+ const [config, setConfig] = useState(() => {
1218
+ return applyPredictionLaunch(initialConfig);
1219
+ });
1220
+ const [showOnboarding, setShowOnboarding] = useState(!initialConfig.onboardingComplete);
1221
+
1222
+ useEffect(() => bindAppActivity(renderer), [renderer]);
1223
+
1224
+ // Apply saved theme before first render
1225
+ if (config.theme) applyTheme(config.theme);
1226
+
1227
+ const services = useMemo(() => {
1228
+ const dbPath = join(config.dataDir, ".gloomberb-cache.db");
1229
+ const persistence = new AppPersistence(dbPath);
1230
+ const tickerRepository = new TickerRepository(persistence.tickers);
1231
+ const providerRouter = new ProviderRouter(null, [], persistence.resources);
1232
+ const dataProvider: DataProvider = providerRouter;
1233
+ const marketData = new MarketDataCoordinator(dataProvider);
1234
+ const pluginRegistry = new PluginRegistry(renderer, dataProvider, tickerRepository, persistence);
1235
+ providerRouter.attachRegistry(pluginRegistry);
1236
+ pluginRegistry.getConfigFn = () => config;
1237
+ pluginRegistry.getLayoutFn = () => config.layout;
1238
+
1239
+ pluginRegistry.register(yahooPlugin);
1240
+ pluginRegistry.register(gloomberbCloudPlugin);
1241
+ pluginRegistry.register(portfolioListPlugin);
1242
+ pluginRegistry.register(tickerDetailPlugin);
1243
+ pluginRegistry.register(manualEntryPlugin);
1244
+ pluginRegistry.register(ibkrPlugin);
1245
+ pluginRegistry.register(layoutManagerPlugin);
1246
+ pluginRegistry.register(newsPlugin);
1247
+ pluginRegistry.register(secPlugin);
1248
+ pluginRegistry.register(optionsPlugin);
1249
+ pluginRegistry.register(notesPlugin);
1250
+ pluginRegistry.register(aiPlugin);
1251
+ pluginRegistry.register(helpPlugin);
1252
+ pluginRegistry.register(comparisonChartPlugin);
1253
+ pluginRegistry.register(predictionMarketsPlugin);
1254
+ pluginRegistry.register(debugPlugin);
1255
+
1256
+ for (const { plugin, error } of externalPlugins) {
1257
+ if (!error) pluginRegistry.register(plugin);
1258
+ }
1259
+
1260
+ return {
1261
+ persistence,
1262
+ tickerRepository,
1263
+ providerRouter,
1264
+ dataProvider,
1265
+ marketData,
1266
+ pluginRegistry,
1267
+ };
1268
+ }, [config.dataDir, externalPlugins, renderer]);
1269
+
1270
+ if (services.marketData !== null) {
1271
+ setSharedMarketDataCoordinator(services.marketData);
1272
+ }
1273
+
1274
+ useEffect(() => {
1275
+ return () => {
1276
+ setSharedMarketDataCoordinator(null);
1277
+ services.persistence.close();
1278
+ };
1279
+ }, [services]);
1280
+
1281
+ const sessionSnapshot = useMemo(() => {
1282
+ const persisted = services.persistence.sessions.get<AppSessionSnapshot>(APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION)?.value ?? null;
1283
+ const reconciled = reconcileAppSessionSnapshot(config, persisted);
1284
+ if (!predictionLaunchIntent) {
1285
+ return reconciled;
1286
+ }
1287
+ const paneInstanceId = config.layout.instances.find((instance) => instance.paneId === "prediction-markets")?.instanceId;
1288
+ if (!paneInstanceId) {
1289
+ return reconciled;
1290
+ }
1291
+ return applyPredictionLaunchIntentToSessionSnapshot(
1292
+ config,
1293
+ reconciled,
1294
+ paneInstanceId,
1295
+ predictionLaunchIntent,
1296
+ );
1297
+ }, [config, predictionLaunchIntent, services.persistence.sessions]);
1298
+
1299
+ if (showOnboarding) {
1300
+ return (
1301
+ <OnboardingWizard
1302
+ config={config}
1303
+ pluginRegistry={services.pluginRegistry}
1304
+ onComplete={(updatedConfig) => {
1305
+ setConfig(applyPredictionLaunch(updatedConfig));
1306
+ setShowOnboarding(false);
1307
+ }}
1308
+ />
1309
+ );
1310
+ }
1311
+
1312
+ return (
1313
+ <AppProvider config={config} sessionStore={services.persistence.sessions} sessionSnapshot={sessionSnapshot}>
1314
+ <DialogProvider
1315
+ size="medium"
1316
+ dialogOptions={{ style: { backgroundColor: colors.bg, borderColor: colors.borderFocused, borderStyle: "single", paddingX: 2, paddingY: 1 } }}
1317
+ backdropColor="#000000"
1318
+ backdropOpacity={0.8}
1319
+ >
1320
+ <AppInner
1321
+ pluginRegistry={services.pluginRegistry}
1322
+ tickerRepository={services.tickerRepository}
1323
+ dataProvider={services.dataProvider}
1324
+ marketData={services.marketData}
1325
+ sessionSnapshot={sessionSnapshot}
1326
+ />
1327
+ </DialogProvider>
1328
+ </AppProvider>
1329
+ );
1330
+ }