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
@@ -0,0 +1,969 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useReducer,
7
+ useRef,
8
+ type SetStateAction,
9
+ type ReactNode,
10
+ } from "react";
11
+ import type { SessionStore } from "../data/session-store";
12
+ import { saveConfig } from "../data/config-store";
13
+ import { applyTheme } from "../theme/colors";
14
+ import { isBrokerPortfolioId } from "../utils/broker-instances";
15
+ import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
16
+ import {
17
+ cloneLayout,
18
+ DEFAULT_LAYOUT,
19
+ findPaneInstance,
20
+ isFixedTickerPane,
21
+ normalizePaneLayout,
22
+ removePaneInstances,
23
+ type LayoutConfig,
24
+ } from "../types/config";
25
+ import { setPaneSetting } from "../pane-settings";
26
+ import type { AppConfig, PaneBinding, PaneInstanceConfig, SavedLayout } from "../types/config";
27
+ import type { Quote, TickerFinancials } from "../types/financials";
28
+ import type { TickerRecord } from "../types/ticker";
29
+ import type { BrokerAccount } from "../types/trading";
30
+ import type { ReleaseInfo, UpdateProgress } from "../updater";
31
+ import { getDockLeafLayouts, getDockedPaneIds } from "../plugins/pane-manager";
32
+ import { useTickerFinancials } from "../market-data/hooks";
33
+ import {
34
+ APP_SESSION_ID,
35
+ APP_SESSION_SCHEMA_VERSION,
36
+ type AppSessionSnapshot,
37
+ } from "./session-persistence";
38
+ import { usePersistSessionSnapshot } from "./use-session-snapshot";
39
+
40
+ export interface PaneRuntimeState {
41
+ cursorSymbol?: string | null;
42
+ collectionId?: string;
43
+ activeTabId?: string;
44
+ collectionSorts?: Record<string, CollectionSortPreference>;
45
+ pluginState?: Record<string, Record<string, unknown>>;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export interface LayoutHistoryEntry {
50
+ past: LayoutConfig[];
51
+ future: LayoutConfig[];
52
+ }
53
+
54
+ export type SortDirection = "asc" | "desc";
55
+
56
+ export interface CollectionSortPreference {
57
+ columnId: string | null;
58
+ direction: SortDirection;
59
+ }
60
+
61
+ export interface AppState {
62
+ config: AppConfig;
63
+ tickers: Map<string, TickerRecord>;
64
+ financials: Map<string, TickerFinancials>;
65
+ exchangeRates: Map<string, number>;
66
+ brokerAccounts: Record<string, BrokerAccount[]>;
67
+ activePanel: "left" | "right";
68
+ focusedPaneId: string | null;
69
+ paneState: Record<string, PaneRuntimeState>;
70
+ recentTickers: string[];
71
+ commandBarOpen: boolean;
72
+ commandBarQuery: string;
73
+ refreshing: Set<string>;
74
+ initialized: boolean;
75
+ statusBarVisible: boolean;
76
+ gridlockTipVisible: boolean;
77
+ gridlockTipSequence: number;
78
+ inputCaptured: boolean;
79
+ updateAvailable: ReleaseInfo | null;
80
+ updateProgress: UpdateProgress | null;
81
+ layoutHistory: Record<number, LayoutHistoryEntry>;
82
+ }
83
+
84
+ export type AppAction =
85
+ | { type: "SET_CONFIG"; config: AppConfig }
86
+ | { type: "SET_TICKERS"; tickers: Map<string, TickerRecord> }
87
+ | { type: "UPDATE_TICKER"; ticker: TickerRecord }
88
+ | { type: "REMOVE_TICKER"; symbol: string }
89
+ | { type: "SET_FINANCIALS"; symbol: string; data: TickerFinancials }
90
+ | { type: "MERGE_QUOTE"; symbol: string; quote: Quote }
91
+ | { type: "HYDRATE_FINANCIALS"; financials: Map<string, TickerFinancials> }
92
+ | { type: "TRACK_TICKER"; symbol: string | null }
93
+ | { type: "SET_ACTIVE_PANEL"; panel: "left" | "right" }
94
+ | { type: "TOGGLE_COMMAND_BAR" }
95
+ | { type: "SET_COMMAND_BAR"; open: boolean; query?: string }
96
+ | { type: "SET_COMMAND_BAR_QUERY"; query: string }
97
+ | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
98
+ | { type: "SET_BROKER_ACCOUNTS"; instanceId: string; accounts: BrokerAccount[] }
99
+ | { type: "SET_INITIALIZED" }
100
+ | { type: "TOGGLE_STATUS_BAR" }
101
+ | { type: "SHOW_GRIDLOCK_TIP" }
102
+ | { type: "DISMISS_GRIDLOCK_TIP" }
103
+ | { type: "SET_THEME"; theme: string }
104
+ | { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo }
105
+ | { type: "SET_UPDATE_PROGRESS"; progress: UpdateProgress | null }
106
+ | { type: "TOGGLE_PLUGIN"; pluginId: string }
107
+ | { type: "SET_INPUT_CAPTURED"; captured: boolean }
108
+ | { type: "SET_EXCHANGE_RATE"; currency: string; rate: number }
109
+ | { type: "HYDRATE_EXCHANGE_RATES"; exchangeRates: Map<string, number> }
110
+ | { type: "PUSH_LAYOUT_HISTORY" }
111
+ | { type: "UNDO_LAYOUT" }
112
+ | { type: "REDO_LAYOUT" }
113
+ | { type: "UPDATE_LAYOUT"; layout: LayoutConfig }
114
+ | { type: "SWITCH_LAYOUT"; index: number }
115
+ | { type: "NEW_LAYOUT"; name: string }
116
+ | { type: "DELETE_LAYOUT"; index: number }
117
+ | { type: "RENAME_LAYOUT"; index: number; name: string }
118
+ | { type: "DUPLICATE_LAYOUT"; index: number }
119
+ | { type: "FOCUS_PANE"; paneId: string }
120
+ | { type: "FOCUS_NEXT"; paneOrder: string[] }
121
+ | { type: "FOCUS_PREV"; paneOrder: string[] }
122
+ | {
123
+ type: "UPDATE_PLUGIN_PANE_STATE";
124
+ paneId: string;
125
+ pluginId: string;
126
+ key: string;
127
+ value: unknown;
128
+ }
129
+ | { type: "UPDATE_PANE_STATE"; paneId: string; patch: Partial<PaneRuntimeState> };
130
+
131
+ function getDefaultCollectionId(config: AppConfig): string {
132
+ return config.portfolios[0]?.id || config.watchlists[0]?.id || "";
133
+ }
134
+
135
+ function isKnownCollection(config: AppConfig, collectionId: string | undefined): collectionId is string {
136
+ if (!collectionId) return false;
137
+ return config.portfolios.some((portfolio) => portfolio.id === collectionId)
138
+ || config.watchlists.some((watchlist) => watchlist.id === collectionId);
139
+ }
140
+
141
+ function shouldPreserveUnknownCollectionId(collectionId: string | undefined): boolean {
142
+ return isBrokerPortfolioId(collectionId);
143
+ }
144
+
145
+ function hasLikelyPriceUnitMismatch(current: Quote | undefined, next: Quote): boolean {
146
+ return hasLikelyQuoteUnitMismatch(current, next);
147
+ }
148
+
149
+ function getConfiguredCollectionId(config: AppConfig, instance: PaneInstanceConfig): string {
150
+ const candidates = [
151
+ instance.params?.collectionId,
152
+ typeof instance.settings?.lockedCollectionId === "string" ? instance.settings.lockedCollectionId : undefined,
153
+ ...(Array.isArray(instance.settings?.visibleCollectionIds)
154
+ ? instance.settings.visibleCollectionIds.filter((value): value is string => typeof value === "string")
155
+ : []),
156
+ ];
157
+
158
+ for (const candidate of candidates) {
159
+ if (isKnownCollection(config, candidate) || shouldPreserveUnknownCollectionId(candidate)) {
160
+ return candidate!;
161
+ }
162
+ }
163
+
164
+ return getDefaultCollectionId(config);
165
+ }
166
+
167
+ function defaultPaneStateForInstance(config: AppConfig, instance: PaneInstanceConfig): PaneRuntimeState {
168
+ if (instance.paneId === "portfolio-list") {
169
+ return {
170
+ collectionId: getConfiguredCollectionId(config, instance),
171
+ cursorSymbol: null,
172
+ };
173
+ }
174
+ if (instance.paneId === "ticker-detail") {
175
+ return { activeTabId: "overview" };
176
+ }
177
+ return {};
178
+ }
179
+
180
+ function reconcilePaneState(config: AppConfig, previous: Record<string, PaneRuntimeState>): Record<string, PaneRuntimeState> {
181
+ const next: Record<string, PaneRuntimeState> = {};
182
+ for (const instance of config.layout.instances) {
183
+ const defaults = defaultPaneStateForInstance(config, instance);
184
+ const paneState = { ...defaults, ...(previous[instance.instanceId] ?? {}) };
185
+ if (
186
+ instance.paneId === "portfolio-list"
187
+ && !isKnownCollection(config, paneState.collectionId as string | undefined)
188
+ && !shouldPreserveUnknownCollectionId(paneState.collectionId as string | undefined)
189
+ ) {
190
+ paneState.collectionId = defaults.collectionId;
191
+ }
192
+ next[instance.instanceId] = paneState;
193
+ }
194
+ return next;
195
+ }
196
+
197
+ function reconcileBrokerAccounts(
198
+ config: AppConfig,
199
+ brokerAccounts: Record<string, BrokerAccount[]>,
200
+ ): Record<string, BrokerAccount[]> {
201
+ const validInstanceIds = new Set(config.brokerInstances.map((instance) => instance.id));
202
+ return Object.fromEntries(
203
+ Object.entries(brokerAccounts).filter(([instanceId]) => validInstanceIds.has(instanceId)),
204
+ );
205
+ }
206
+
207
+ function resolveTickerFromBinding(
208
+ state: Pick<AppState, "config" | "paneState">,
209
+ binding: PaneBinding | undefined,
210
+ seen: Set<string>,
211
+ ): string | null {
212
+ if (!binding || binding.kind === "none") return null;
213
+ if (binding.kind === "fixed") return binding.symbol;
214
+ return resolveTickerForPane(state as AppState, binding.sourceInstanceId, seen);
215
+ }
216
+
217
+ export function getPaneState(state: Pick<AppState, "paneState">, paneId: string): PaneRuntimeState {
218
+ return state.paneState[paneId] ?? {};
219
+ }
220
+
221
+ export function resolveTickerForPane(state: AppState, paneId: string, seen = new Set<string>()): string | null {
222
+ if (seen.has(paneId)) return null;
223
+ seen.add(paneId);
224
+ const instance = findPaneInstance(state.config.layout, paneId);
225
+ if (!instance) return null;
226
+ if (instance.paneId === "portfolio-list") {
227
+ const paneState = getPaneState(state, paneId);
228
+ return typeof paneState.cursorSymbol === "string" ? paneState.cursorSymbol : null;
229
+ }
230
+ return resolveTickerFromBinding(state, instance.binding, seen);
231
+ }
232
+
233
+ export function resolveCollectionForPane(state: AppState, paneId: string, seen = new Set<string>()): string | null {
234
+ if (seen.has(paneId)) return null;
235
+ seen.add(paneId);
236
+ const instance = findPaneInstance(state.config.layout, paneId);
237
+ if (!instance) return null;
238
+ if (instance.paneId === "portfolio-list") {
239
+ const paneState = getPaneState(state, paneId);
240
+ const collectionId = typeof paneState.collectionId === "string"
241
+ ? paneState.collectionId
242
+ : getConfiguredCollectionId(state.config, instance);
243
+ if (isKnownCollection(state.config, collectionId) || shouldPreserveUnknownCollectionId(collectionId)) {
244
+ return collectionId ?? null;
245
+ }
246
+ return getConfiguredCollectionId(state.config, instance);
247
+ }
248
+ if (instance.binding?.kind === "follow") {
249
+ return resolveCollectionForPane(state, instance.binding.sourceInstanceId, seen);
250
+ }
251
+ return null;
252
+ }
253
+
254
+ export function resolveTickerFileForPane(state: AppState, paneId: string): TickerRecord | null {
255
+ const symbol = resolveTickerForPane(state, paneId);
256
+ return symbol ? state.tickers.get(symbol) ?? null : null;
257
+ }
258
+
259
+ export function resolveFinancialsForPane(state: AppState, paneId: string): TickerFinancials | null {
260
+ const symbol = resolveTickerForPane(state, paneId);
261
+ return symbol ? state.financials.get(symbol) ?? null : null;
262
+ }
263
+
264
+ export function getFocusedTickerSymbol(state: AppState): string | null {
265
+ return state.focusedPaneId ? resolveTickerForPane(state, state.focusedPaneId) : null;
266
+ }
267
+
268
+ export function getFocusedCollectionId(state: AppState): string | null {
269
+ return state.focusedPaneId ? resolveCollectionForPane(state, state.focusedPaneId) : null;
270
+ }
271
+
272
+ function clearTickerBindings(layout: LayoutConfig, symbol: string): LayoutConfig {
273
+ return normalizePaneLayout(removePaneInstances(
274
+ layout,
275
+ layout.instances
276
+ .filter((instance) => instance.binding?.kind === "fixed" && isFixedTickerPane(instance) && instance.binding.symbol === symbol)
277
+ .map((instance) => instance.instanceId),
278
+ ));
279
+ }
280
+
281
+ function nextRecentTickers(current: string[], symbol: string | null): string[] {
282
+ if (!symbol) return current;
283
+ return [symbol, ...current.filter((entry) => entry !== symbol)].slice(0, 50);
284
+ }
285
+
286
+ function shouldPreserveExistingQuote(current: Quote | undefined, next: Quote): boolean {
287
+ return current?.dataSource === "live"
288
+ && current.providerId !== "gloomberb-cloud"
289
+ && next.providerId === "gloomberb-cloud";
290
+ }
291
+
292
+ function syncLayouts(layouts: SavedLayout[], activeLayoutIndex: number, layout: LayoutConfig): SavedLayout[] {
293
+ return layouts.map((savedLayout, index) => (
294
+ index === activeLayoutIndex ? { ...savedLayout, layout: cloneLayout(layout) } : savedLayout
295
+ ));
296
+ }
297
+
298
+ const PANEL_RESOLUTION_BOUNDS = { x: 0, y: 0, width: 120, height: 40 };
299
+
300
+ function getPaneOrder(layout: LayoutConfig): string[] {
301
+ return [
302
+ ...getDockedPaneIds(layout),
303
+ ...layout.floating.map((entry) => entry.instanceId),
304
+ ];
305
+ }
306
+
307
+ function getTopFloatingPaneId(layout: LayoutConfig): string | null {
308
+ let topInstanceId: string | null = null;
309
+ let topZIndex = Number.NEGATIVE_INFINITY;
310
+ let topOrder = -1;
311
+
312
+ layout.floating.forEach((entry, index) => {
313
+ const zIndex = entry.zIndex ?? 50;
314
+ if (zIndex > topZIndex || (zIndex === topZIndex && index > topOrder)) {
315
+ topInstanceId = entry.instanceId;
316
+ topZIndex = zIndex;
317
+ topOrder = index;
318
+ }
319
+ });
320
+
321
+ return topInstanceId;
322
+ }
323
+
324
+ function resolveFocusedPaneId(
325
+ previousLayout: LayoutConfig,
326
+ nextLayout: LayoutConfig,
327
+ focusedPaneId: string | null,
328
+ ): string | null {
329
+ const paneOrder = getPaneOrder(nextLayout);
330
+ if (paneOrder.length === 0) return null;
331
+ if (focusedPaneId && paneOrder.includes(focusedPaneId)) {
332
+ return focusedPaneId;
333
+ }
334
+ const previouslyFloating = focusedPaneId
335
+ ? previousLayout.floating.some((entry) => entry.instanceId === focusedPaneId)
336
+ : false;
337
+ if (previouslyFloating) {
338
+ return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
339
+ }
340
+ return paneOrder[0] ?? null;
341
+ }
342
+
343
+ function getPanelFocusTarget(layout: LayoutConfig, panel: "left" | "right"): string | null {
344
+ const leaves = getDockLeafLayouts(layout, PANEL_RESOLUTION_BOUNDS);
345
+ if (leaves.length === 0) return layout.floating[0]?.instanceId ?? null;
346
+ const sorted = [...leaves].sort((a, b) => (
347
+ panel === "left"
348
+ ? a.rect.x - b.rect.x || a.rect.y - b.rect.y
349
+ : (b.rect.x + b.rect.width) - (a.rect.x + a.rect.width) || a.rect.y - b.rect.y
350
+ ));
351
+ return sorted[0]?.instanceId ?? null;
352
+ }
353
+
354
+ const MAX_LAYOUT_HISTORY = 50;
355
+
356
+ function cloneHistoryEntry(entry: LayoutHistoryEntry | undefined): LayoutHistoryEntry {
357
+ return {
358
+ past: entry?.past.map((layout) => cloneLayout(layout)) ?? [],
359
+ future: entry?.future.map((layout) => cloneLayout(layout)) ?? [],
360
+ };
361
+ }
362
+
363
+ function historyForIndex(layoutHistory: Record<number, LayoutHistoryEntry>, index: number): LayoutHistoryEntry {
364
+ return cloneHistoryEntry(layoutHistory[index]);
365
+ }
366
+
367
+ function setHistoryForIndex(
368
+ layoutHistory: Record<number, LayoutHistoryEntry>,
369
+ index: number,
370
+ entry: LayoutHistoryEntry,
371
+ ): Record<number, LayoutHistoryEntry> {
372
+ return {
373
+ ...layoutHistory,
374
+ [index]: {
375
+ past: entry.past.map((layout) => cloneLayout(layout)),
376
+ future: entry.future.map((layout) => cloneLayout(layout)),
377
+ },
378
+ };
379
+ }
380
+
381
+ function removeHistoryIndex(layoutHistory: Record<number, LayoutHistoryEntry>, removedIndex: number): Record<number, LayoutHistoryEntry> {
382
+ const next: Record<number, LayoutHistoryEntry> = {};
383
+ for (const [rawIndex, entry] of Object.entries(layoutHistory)) {
384
+ const index = Number.parseInt(rawIndex, 10);
385
+ if (Number.isNaN(index) || index === removedIndex) continue;
386
+ next[index > removedIndex ? index - 1 : index] = cloneHistoryEntry(entry);
387
+ }
388
+ return next;
389
+ }
390
+
391
+ function withFocusedPane(state: AppState, config: AppConfig): AppState {
392
+ const normalizedLayout = normalizePaneLayout(config.layout);
393
+ const nextConfig = normalizedLayout === config.layout
394
+ ? config
395
+ : {
396
+ ...config,
397
+ layout: normalizedLayout,
398
+ layouts: syncLayouts(config.layouts, config.activeLayoutIndex, normalizedLayout),
399
+ };
400
+ const nextPaneState = reconcilePaneState(nextConfig, state.paneState);
401
+ const focusedPaneId = resolveFocusedPaneId(state.config.layout, nextConfig.layout, state.focusedPaneId);
402
+ return {
403
+ ...state,
404
+ config: nextConfig,
405
+ paneState: nextPaneState,
406
+ brokerAccounts: reconcileBrokerAccounts(nextConfig, state.brokerAccounts),
407
+ focusedPaneId,
408
+ };
409
+ }
410
+
411
+ export function appReducer(state: AppState, action: AppAction): AppState {
412
+ switch (action.type) {
413
+ case "SET_CONFIG":
414
+ return withFocusedPane({ ...state, layoutHistory: {} }, action.config);
415
+
416
+ case "SET_TICKERS":
417
+ return { ...state, tickers: action.tickers };
418
+
419
+ case "UPDATE_TICKER": {
420
+ const tickers = new Map(state.tickers);
421
+ tickers.set(action.ticker.metadata.ticker, action.ticker);
422
+ return { ...state, tickers };
423
+ }
424
+
425
+ case "REMOVE_TICKER": {
426
+ const tickers = new Map(state.tickers);
427
+ tickers.delete(action.symbol);
428
+ const financials = new Map(state.financials);
429
+ financials.delete(action.symbol);
430
+ const paneState: Record<string, PaneRuntimeState> = {};
431
+ for (const [paneId, currentState] of Object.entries(state.paneState)) {
432
+ paneState[paneId] = currentState.cursorSymbol === action.symbol
433
+ ? { ...currentState, cursorSymbol: null }
434
+ : currentState;
435
+ }
436
+ const layout = clearTickerBindings(state.config.layout, action.symbol);
437
+ const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout);
438
+ return {
439
+ ...state,
440
+ tickers,
441
+ financials,
442
+ paneState,
443
+ recentTickers: state.recentTickers.filter((symbol) => symbol !== action.symbol),
444
+ config: { ...state.config, layout, layouts },
445
+ };
446
+ }
447
+
448
+ case "SET_FINANCIALS": {
449
+ const financials = new Map(state.financials);
450
+ financials.set(action.symbol, action.data);
451
+ return { ...state, financials };
452
+ }
453
+
454
+ case "MERGE_QUOTE": {
455
+ const current = state.financials.get(action.symbol);
456
+ if (
457
+ shouldPreserveExistingQuote(current?.quote, action.quote)
458
+ || hasLikelyPriceUnitMismatch(current?.quote, action.quote)
459
+ ) {
460
+ return state;
461
+ }
462
+ const financials = new Map(state.financials);
463
+ financials.set(action.symbol, {
464
+ annualStatements: current?.annualStatements ?? [],
465
+ quarterlyStatements: current?.quarterlyStatements ?? [],
466
+ priceHistory: current?.priceHistory ?? [],
467
+ fundamentals: current?.fundamentals,
468
+ profile: current?.profile,
469
+ quote: {
470
+ ...(current?.quote ?? {}),
471
+ ...action.quote,
472
+ },
473
+ });
474
+ return { ...state, financials };
475
+ }
476
+
477
+ case "HYDRATE_FINANCIALS": {
478
+ if (action.financials.size === 0) return state;
479
+ const financials = new Map(state.financials);
480
+ for (const [symbol, data] of action.financials) {
481
+ financials.set(symbol, data);
482
+ }
483
+ return { ...state, financials };
484
+ }
485
+
486
+ case "TRACK_TICKER":
487
+ return { ...state, recentTickers: nextRecentTickers(state.recentTickers, action.symbol) };
488
+
489
+ case "SET_ACTIVE_PANEL": {
490
+ const firstPane = getPanelFocusTarget(state.config.layout, action.panel);
491
+ return {
492
+ ...state,
493
+ activePanel: action.panel,
494
+ focusedPaneId: firstPane ?? state.focusedPaneId,
495
+ };
496
+ }
497
+
498
+ case "TOGGLE_COMMAND_BAR":
499
+ return state.commandBarOpen
500
+ ? { ...state, commandBarOpen: false, commandBarQuery: "" }
501
+ : { ...state, commandBarOpen: true, commandBarQuery: "" };
502
+
503
+ case "SET_COMMAND_BAR":
504
+ return {
505
+ ...state,
506
+ commandBarOpen: action.open,
507
+ commandBarQuery: action.open ? (action.query ?? "") : "",
508
+ };
509
+
510
+ case "SET_COMMAND_BAR_QUERY":
511
+ return { ...state, commandBarQuery: action.query };
512
+
513
+ case "SET_REFRESHING": {
514
+ const refreshing = new Set(state.refreshing);
515
+ if (action.refreshing) refreshing.add(action.symbol);
516
+ else refreshing.delete(action.symbol);
517
+ return { ...state, refreshing };
518
+ }
519
+
520
+ case "SET_BROKER_ACCOUNTS":
521
+ return {
522
+ ...state,
523
+ brokerAccounts: {
524
+ ...state.brokerAccounts,
525
+ [action.instanceId]: action.accounts,
526
+ },
527
+ };
528
+
529
+ case "SET_INITIALIZED":
530
+ return { ...state, initialized: true };
531
+
532
+ case "TOGGLE_STATUS_BAR":
533
+ return { ...state, statusBarVisible: !state.statusBarVisible };
534
+
535
+ case "SHOW_GRIDLOCK_TIP":
536
+ return {
537
+ ...state,
538
+ gridlockTipVisible: true,
539
+ gridlockTipSequence: state.gridlockTipSequence + 1,
540
+ };
541
+
542
+ case "DISMISS_GRIDLOCK_TIP":
543
+ if (!state.gridlockTipVisible) return state;
544
+ return { ...state, gridlockTipVisible: false };
545
+
546
+ case "SET_THEME":
547
+ applyTheme(action.theme);
548
+ return { ...state, config: { ...state.config, theme: action.theme } };
549
+
550
+ case "SET_UPDATE_AVAILABLE":
551
+ return { ...state, updateAvailable: action.release };
552
+
553
+ case "SET_UPDATE_PROGRESS":
554
+ return { ...state, updateProgress: action.progress };
555
+
556
+ case "TOGGLE_PLUGIN": {
557
+ const disabledPlugins = state.config.disabledPlugins.includes(action.pluginId)
558
+ ? state.config.disabledPlugins.filter((pluginId) => pluginId !== action.pluginId)
559
+ : [...state.config.disabledPlugins, action.pluginId];
560
+ return { ...state, config: { ...state.config, disabledPlugins } };
561
+ }
562
+
563
+ case "SET_INPUT_CAPTURED":
564
+ return { ...state, inputCaptured: action.captured };
565
+
566
+ case "SET_EXCHANGE_RATE": {
567
+ const exchangeRates = new Map(state.exchangeRates);
568
+ exchangeRates.set(action.currency, action.rate);
569
+ return { ...state, exchangeRates };
570
+ }
571
+
572
+ case "HYDRATE_EXCHANGE_RATES": {
573
+ if (action.exchangeRates.size === 0) return state;
574
+ const exchangeRates = new Map(state.exchangeRates);
575
+ for (const [currency, rate] of action.exchangeRates) {
576
+ exchangeRates.set(currency, rate);
577
+ }
578
+ return { ...state, exchangeRates };
579
+ }
580
+
581
+ case "PUSH_LAYOUT_HISTORY": {
582
+ const currentIndex = state.config.activeLayoutIndex;
583
+ const entry = historyForIndex(state.layoutHistory, currentIndex);
584
+ const snapshot = cloneLayout(state.config.layout);
585
+ const last = entry.past[entry.past.length - 1];
586
+ if (last && JSON.stringify(last) === JSON.stringify(snapshot)) {
587
+ return state;
588
+ }
589
+ entry.past = [...entry.past, snapshot].slice(-MAX_LAYOUT_HISTORY);
590
+ entry.future = [];
591
+ return {
592
+ ...state,
593
+ layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
594
+ };
595
+ }
596
+
597
+ case "UNDO_LAYOUT": {
598
+ const currentIndex = state.config.activeLayoutIndex;
599
+ const entry = historyForIndex(state.layoutHistory, currentIndex);
600
+ if (entry.past.length === 0) return state;
601
+ const target = entry.past[entry.past.length - 1]!;
602
+ entry.past = entry.past.slice(0, -1);
603
+ entry.future = [cloneLayout(state.config.layout), ...entry.future].slice(0, MAX_LAYOUT_HISTORY);
604
+ const layouts = syncLayouts(state.config.layouts, currentIndex, target);
605
+ return withFocusedPane({
606
+ ...state,
607
+ layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
608
+ }, {
609
+ ...state.config,
610
+ layout: cloneLayout(target),
611
+ layouts,
612
+ });
613
+ }
614
+
615
+ case "REDO_LAYOUT": {
616
+ const currentIndex = state.config.activeLayoutIndex;
617
+ const entry = historyForIndex(state.layoutHistory, currentIndex);
618
+ if (entry.future.length === 0) return state;
619
+ const target = entry.future[0]!;
620
+ entry.future = entry.future.slice(1);
621
+ entry.past = [...entry.past, cloneLayout(state.config.layout)].slice(-MAX_LAYOUT_HISTORY);
622
+ const layouts = syncLayouts(state.config.layouts, currentIndex, target);
623
+ return withFocusedPane({
624
+ ...state,
625
+ layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
626
+ }, {
627
+ ...state.config,
628
+ layout: cloneLayout(target),
629
+ layouts,
630
+ });
631
+ }
632
+
633
+ case "UPDATE_LAYOUT": {
634
+ const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, action.layout);
635
+ return withFocusedPane(state, { ...state.config, layout: action.layout, layouts });
636
+ }
637
+
638
+ case "SWITCH_LAYOUT": {
639
+ if (action.index < 0 || action.index >= state.config.layouts.length) return state;
640
+ const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout);
641
+ const target = layouts[action.index]!;
642
+ return withFocusedPane(state, {
643
+ ...state.config,
644
+ layout: cloneLayout(target.layout),
645
+ layouts,
646
+ activeLayoutIndex: action.index,
647
+ });
648
+ }
649
+
650
+ case "NEW_LAYOUT": {
651
+ const newLayout: SavedLayout = {
652
+ name: action.name,
653
+ layout: cloneLayout(DEFAULT_LAYOUT),
654
+ };
655
+ const layouts = [...syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout), newLayout];
656
+ return withFocusedPane({
657
+ ...state,
658
+ layoutHistory: setHistoryForIndex(state.layoutHistory, layouts.length - 1, { past: [], future: [] }),
659
+ }, {
660
+ ...state.config,
661
+ layout: cloneLayout(newLayout.layout),
662
+ layouts,
663
+ activeLayoutIndex: layouts.length - 1,
664
+ });
665
+ }
666
+
667
+ case "DELETE_LAYOUT": {
668
+ if (state.config.layouts.length <= 1) return state;
669
+ const layouts = state.config.layouts.filter((_, index) => index !== action.index);
670
+ const nextActiveLayoutIndex = action.index <= state.config.activeLayoutIndex
671
+ ? Math.max(0, state.config.activeLayoutIndex - 1)
672
+ : state.config.activeLayoutIndex;
673
+ const nextLayout = layouts[nextActiveLayoutIndex]!;
674
+ return withFocusedPane({
675
+ ...state,
676
+ layoutHistory: removeHistoryIndex(state.layoutHistory, action.index),
677
+ }, {
678
+ ...state.config,
679
+ layout: cloneLayout(nextLayout.layout),
680
+ layouts,
681
+ activeLayoutIndex: nextActiveLayoutIndex,
682
+ });
683
+ }
684
+
685
+ case "RENAME_LAYOUT": {
686
+ if (action.index < 0 || action.index >= state.config.layouts.length) return state;
687
+ return {
688
+ ...state,
689
+ config: {
690
+ ...state.config,
691
+ layouts: state.config.layouts.map((savedLayout, index) => (
692
+ index === action.index ? { ...savedLayout, name: action.name } : savedLayout
693
+ )),
694
+ },
695
+ };
696
+ }
697
+
698
+ case "DUPLICATE_LAYOUT": {
699
+ if (action.index < 0 || action.index >= state.config.layouts.length) return state;
700
+ const source = state.config.layouts[action.index]!;
701
+ const duplicate: SavedLayout = {
702
+ name: `${source.name} Copy`,
703
+ layout: cloneLayout(source.layout),
704
+ };
705
+ const layouts = [...syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout), duplicate];
706
+ return withFocusedPane({
707
+ ...state,
708
+ layoutHistory: setHistoryForIndex(state.layoutHistory, layouts.length - 1, { past: [], future: [] }),
709
+ }, {
710
+ ...state.config,
711
+ layout: cloneLayout(duplicate.layout),
712
+ layouts,
713
+ activeLayoutIndex: layouts.length - 1,
714
+ });
715
+ }
716
+
717
+ case "FOCUS_PANE":
718
+ return {
719
+ ...state,
720
+ focusedPaneId: action.paneId,
721
+ recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, action.paneId)),
722
+ };
723
+
724
+ case "FOCUS_NEXT": {
725
+ if (action.paneOrder.length === 0) return state;
726
+ const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : -1;
727
+ const nextPaneId = action.paneOrder[(currentIndex + 1) % action.paneOrder.length]!;
728
+ return {
729
+ ...state,
730
+ focusedPaneId: nextPaneId,
731
+ recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
732
+ };
733
+ }
734
+
735
+ case "FOCUS_PREV": {
736
+ if (action.paneOrder.length === 0) return state;
737
+ const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : 0;
738
+ const nextPaneId = action.paneOrder[(currentIndex - 1 + action.paneOrder.length) % action.paneOrder.length]!;
739
+ return {
740
+ ...state,
741
+ focusedPaneId: nextPaneId,
742
+ recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
743
+ };
744
+ }
745
+
746
+ case "UPDATE_PANE_STATE": {
747
+ const current = state.paneState[action.paneId] ?? {};
748
+ const nextState = { ...current, ...action.patch };
749
+ const recentTickers = Object.prototype.hasOwnProperty.call(action.patch, "cursorSymbol")
750
+ ? nextRecentTickers(state.recentTickers, typeof nextState.cursorSymbol === "string" ? nextState.cursorSymbol : null)
751
+ : state.recentTickers;
752
+ return {
753
+ ...state,
754
+ paneState: {
755
+ ...state.paneState,
756
+ [action.paneId]: nextState,
757
+ },
758
+ recentTickers,
759
+ };
760
+ }
761
+
762
+ case "UPDATE_PLUGIN_PANE_STATE": {
763
+ const current = state.paneState[action.paneId] ?? {};
764
+ const currentPluginState = current.pluginState ?? {};
765
+ const currentPluginValues = currentPluginState[action.pluginId] ?? {};
766
+ if (Object.is(currentPluginValues[action.key], action.value)) {
767
+ return state;
768
+ }
769
+ return {
770
+ ...state,
771
+ paneState: {
772
+ ...state.paneState,
773
+ [action.paneId]: {
774
+ ...current,
775
+ pluginState: {
776
+ ...currentPluginState,
777
+ [action.pluginId]: {
778
+ ...currentPluginValues,
779
+ [action.key]: action.value,
780
+ },
781
+ },
782
+ },
783
+ },
784
+ };
785
+ }
786
+
787
+ default:
788
+ return state;
789
+ }
790
+ }
791
+
792
+ interface AppContextValue {
793
+ state: AppState;
794
+ dispatch: React.Dispatch<AppAction>;
795
+ }
796
+
797
+ export const AppContext = createContext<AppContextValue | null>(null);
798
+ const PaneContext = createContext<string | null>(null);
799
+
800
+ export function useAppState(): AppContextValue {
801
+ const context = useContext(AppContext);
802
+ if (!context) throw new Error("useAppState must be used within AppProvider");
803
+ return context;
804
+ }
805
+
806
+ export function PaneInstanceProvider({ paneId, children }: { paneId: string; children: ReactNode }) {
807
+ return <PaneContext value={paneId}>{children}</PaneContext>;
808
+ }
809
+
810
+ export function usePaneInstanceId(): string {
811
+ const paneId = useContext(PaneContext);
812
+ if (!paneId) throw new Error("Pane hooks require a pane instance context");
813
+ return paneId;
814
+ }
815
+
816
+ export function usePaneInstance(): PaneInstanceConfig | null {
817
+ const { state } = useAppState();
818
+ const paneId = useContext(PaneContext);
819
+ return paneId ? findPaneInstance(state.config.layout, paneId) ?? null : null;
820
+ }
821
+
822
+ export function usePaneTicker(paneId?: string) {
823
+ const { state } = useAppState();
824
+ const scopedPaneId = paneId ?? useContext(PaneContext) ?? state.focusedPaneId;
825
+ const symbol = useMemo(() => (
826
+ scopedPaneId ? resolveTickerForPane(state, scopedPaneId) : null
827
+ ), [state, scopedPaneId]);
828
+ const ticker = useMemo(() => (
829
+ symbol ? state.tickers.get(symbol) ?? null : null
830
+ ), [state.tickers, symbol]);
831
+ const marketFinancials = useTickerFinancials(symbol, ticker);
832
+
833
+ return useMemo(() => {
834
+ if (!scopedPaneId) return { symbol: null, ticker: null, financials: null };
835
+ return {
836
+ symbol,
837
+ ticker,
838
+ financials: marketFinancials ?? (symbol ? state.financials.get(symbol) ?? null : null),
839
+ };
840
+ }, [marketFinancials, scopedPaneId, state.financials, symbol, ticker]);
841
+ }
842
+
843
+ export function useSelectedTicker(paneId?: string) {
844
+ return usePaneTicker(paneId);
845
+ }
846
+
847
+ export function useFocusedTicker() {
848
+ const { state } = useAppState();
849
+ const symbol = useMemo(() => getFocusedTickerSymbol(state), [state]);
850
+ const ticker = useMemo(() => (symbol ? state.tickers.get(symbol) ?? null : null), [state.tickers, symbol]);
851
+ const marketFinancials = useTickerFinancials(symbol, ticker);
852
+ return useMemo(() => {
853
+ return {
854
+ symbol,
855
+ ticker,
856
+ financials: marketFinancials ?? (symbol ? state.financials.get(symbol) ?? null : null),
857
+ };
858
+ }, [marketFinancials, state.financials, symbol, ticker]);
859
+ }
860
+
861
+ export function usePaneCollection(paneId?: string) {
862
+ const { state } = useAppState();
863
+ const scopedPaneId = paneId ?? useContext(PaneContext) ?? state.focusedPaneId;
864
+ return useMemo(() => {
865
+ if (!scopedPaneId) return { collectionId: null, portfolio: null, watchlist: null };
866
+ const collectionId = resolveCollectionForPane(state, scopedPaneId);
867
+ return {
868
+ collectionId,
869
+ portfolio: collectionId ? state.config.portfolios.find((portfolio) => portfolio.id === collectionId) ?? null : null,
870
+ watchlist: collectionId ? state.config.watchlists.find((watchlist) => watchlist.id === collectionId) ?? null : null,
871
+ };
872
+ }, [state, scopedPaneId]);
873
+ }
874
+
875
+ export function usePaneStateValue<T>(key: string, fallback: T, paneId?: string): [T, (value: T) => void] {
876
+ const { state, dispatch } = useAppState();
877
+ const scopedPaneId = paneId ?? usePaneInstanceId();
878
+ const value = (state.paneState[scopedPaneId]?.[key] as T | undefined) ?? fallback;
879
+ const setValue = (nextValue: T) => {
880
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: scopedPaneId, patch: { [key]: nextValue } });
881
+ };
882
+ return [value, setValue];
883
+ }
884
+
885
+ export function usePaneSettingValue<T>(
886
+ key: string,
887
+ fallback: T,
888
+ paneId?: string,
889
+ ): [T, (value: SetStateAction<T>) => void] {
890
+ const { state, dispatch } = useAppState();
891
+ const scopedPaneId = paneId ?? usePaneInstanceId();
892
+ const instance = findPaneInstance(state.config.layout, scopedPaneId);
893
+ const value = (instance?.settings?.[key] as T | undefined) ?? fallback;
894
+
895
+ const setValue = (nextValue: SetStateAction<T>) => {
896
+ const currentValue = (findPaneInstance(state.config.layout, scopedPaneId)?.settings?.[key] as T | undefined) ?? fallback;
897
+ const resolved = typeof nextValue === "function"
898
+ ? (nextValue as (previousValue: T) => T)(currentValue)
899
+ : nextValue;
900
+ const layout = setPaneSetting(state.config.layout, scopedPaneId, key, resolved);
901
+ const layouts = state.config.layouts.map((savedLayout, index) => (
902
+ index === state.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
903
+ ));
904
+ const nextConfig = { ...state.config, layout, layouts };
905
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
906
+ saveConfig(nextConfig).catch(() => {});
907
+ };
908
+
909
+ return [value, setValue];
910
+ }
911
+
912
+ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessionSnapshot | null = null): AppState {
913
+ const paneState = reconcilePaneState(config, sessionSnapshot?.paneState ?? {});
914
+ const defaultFocusedPaneId = getPaneOrder(config.layout)[0] ?? null;
915
+ const focusedPaneId = sessionSnapshot?.focusedPaneId
916
+ && config.layout.instances.some((instance) => instance.instanceId === sessionSnapshot.focusedPaneId)
917
+ ? sessionSnapshot.focusedPaneId
918
+ : defaultFocusedPaneId;
919
+ return {
920
+ config,
921
+ tickers: new Map(),
922
+ financials: new Map(),
923
+ exchangeRates: new Map([["USD", 1]]),
924
+ brokerAccounts: {},
925
+ activePanel: sessionSnapshot?.activePanel === "right" ? "right" : "left",
926
+ focusedPaneId,
927
+ paneState,
928
+ recentTickers: config.recentTickers,
929
+ commandBarOpen: false,
930
+ commandBarQuery: "",
931
+ refreshing: new Set(),
932
+ initialized: false,
933
+ statusBarVisible: sessionSnapshot?.statusBarVisible !== false,
934
+ gridlockTipVisible: false,
935
+ gridlockTipSequence: 0,
936
+ inputCaptured: false,
937
+ updateAvailable: null,
938
+ updateProgress: null,
939
+ layoutHistory: {},
940
+ };
941
+ }
942
+
943
+ export function AppProvider({
944
+ config,
945
+ children,
946
+ sessionStore,
947
+ sessionSnapshot = null,
948
+ }: {
949
+ config: AppConfig;
950
+ children: ReactNode;
951
+ sessionStore?: SessionStore;
952
+ sessionSnapshot?: AppSessionSnapshot | null;
953
+ }) {
954
+ const [state, dispatch] = useReducer(appReducer, { config, sessionSnapshot }, ({ config, sessionSnapshot: initialSessionSnapshot }) => (
955
+ createInitialState(config, initialSessionSnapshot)
956
+ ));
957
+ const previousRecentTickers = useRef(state.recentTickers);
958
+
959
+ useEffect(() => {
960
+ if (previousRecentTickers.current !== state.recentTickers) {
961
+ previousRecentTickers.current = state.recentTickers;
962
+ saveConfig({ ...state.config, recentTickers: state.recentTickers }).catch(() => {});
963
+ }
964
+ }, [state.config, state.recentTickers]);
965
+
966
+ usePersistSessionSnapshot(sessionStore, state, APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION);
967
+
968
+ return <AppContext value={{ state, dispatch }}>{children}</AppContext>;
969
+ }