gloomberb 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/README.md +91 -11
  2. package/package.json +12 -5
  3. package/src/app.tsx +414 -191
  4. package/src/architecture/import-boundaries.test.ts +118 -0
  5. package/src/assets/gloomberb-logo.png +0 -0
  6. package/src/assets/gloomberb-logo.svg +73 -0
  7. package/src/brokers/account-cache.ts +157 -0
  8. package/src/brokers/profile-form.test.ts +73 -0
  9. package/src/brokers/profile-form.ts +109 -0
  10. package/src/brokers/remote-broker-adapter.ts +91 -0
  11. package/src/brokers/sync-broker-instance.test.ts +124 -1
  12. package/src/brokers/sync-broker-instance.ts +113 -17
  13. package/src/capabilities/factories.ts +100 -0
  14. package/src/capabilities/index.ts +4 -0
  15. package/src/capabilities/registry.test.ts +137 -0
  16. package/src/capabilities/registry.ts +131 -0
  17. package/src/capabilities/service-capabilities.ts +27 -0
  18. package/src/capabilities/types.ts +84 -0
  19. package/src/cli/commands/ticker.ts +11 -3
  20. package/src/cli/context.ts +19 -13
  21. package/src/cli/helpers.ts +1 -0
  22. package/src/cli/types.ts +2 -2
  23. package/src/components/chart/chart-controller.ts +4 -24
  24. package/src/components/chart/chart-data.ts +3 -14
  25. package/src/components/chart/chart-indicator-selector.tsx +28 -6
  26. package/src/components/chart/chart-pane-settings.ts +15 -28
  27. package/src/components/chart/chart-renderer.ts +74 -99
  28. package/src/components/chart/chart-resolution.ts +10 -10
  29. package/src/components/chart/chart-scroll.test.ts +6 -5
  30. package/src/components/chart/chart-scroll.ts +2 -2
  31. package/src/components/chart/chart-types.ts +0 -1
  32. package/src/components/chart/chart-viewport.ts +2 -1
  33. package/src/components/chart/chart.test.ts +37 -0
  34. package/src/components/chart/comparison-chart-data.ts +3 -17
  35. package/src/components/chart/comparison-chart-renderer.test.ts +27 -4
  36. package/src/components/chart/comparison-chart-renderer.ts +1 -1
  37. package/src/components/chart/comparison-stock-chart.tsx +304 -108
  38. package/src/components/chart/cursor-motion.ts +3 -2
  39. package/src/components/chart/indicators/bands.test.ts +0 -14
  40. package/src/components/chart/indicators/moving-averages.test.ts +0 -15
  41. package/src/components/chart/indicators/options.ts +3 -3
  42. package/src/components/chart/indicators/oscillators.test.ts +0 -21
  43. package/src/components/chart/indicators/types.ts +1 -2
  44. package/src/components/chart/native/chart-rasterizer.ts +37 -44
  45. package/src/components/chart/native/kitty-adapter.ts +2 -5
  46. package/src/components/chart/native/kitty-manager.ts +1 -1
  47. package/src/components/chart/native/kitty-protocol.ts +0 -1
  48. package/src/components/chart/native/kitty-support.ts +1 -1
  49. package/src/components/chart/native/kitty.test.ts +30 -13
  50. package/src/components/chart/native/renderer-selection.ts +1 -1
  51. package/src/components/chart/native/surface-manager.ts +1 -1
  52. package/src/components/chart/static-chart-surface.test.tsx +51 -0
  53. package/src/components/chart/static-chart-surface.tsx +191 -0
  54. package/src/components/chart/stock-chart-renderer-switch.test.tsx +242 -35
  55. package/src/components/chart/stock-chart.tsx +438 -141
  56. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  57. package/src/components/command-bar/command-bar.test.tsx +269 -20
  58. package/src/components/command-bar/command-bar.tsx +830 -353
  59. package/src/components/command-bar/command-registry.ts +10 -20
  60. package/src/components/command-bar/helpers.test.ts +1 -1
  61. package/src/components/command-bar/helpers.ts +9 -11
  62. package/src/components/command-bar/pane-template-display.ts +4 -8
  63. package/src/components/command-bar/root-shortcuts.ts +12 -11
  64. package/src/components/command-bar/view-model.test.ts +8 -42
  65. package/src/components/command-bar/view-model.ts +8 -36
  66. package/src/components/command-bar/workflow-ops.test.ts +76 -2
  67. package/src/components/command-bar/workflow-ops.ts +40 -9
  68. package/src/components/command-bar/workflow-types.ts +2 -1
  69. package/src/components/data-table-stack-view.test.tsx +206 -0
  70. package/src/components/data-table-stack-view.tsx +152 -0
  71. package/src/components/data-table-view.test.tsx +188 -0
  72. package/src/components/data-table-view.tsx +279 -0
  73. package/src/components/feed-data-table-stack-view.test.tsx +225 -0
  74. package/src/components/feed-data-table-stack-view.tsx +365 -0
  75. package/src/components/index.ts +26 -31
  76. package/src/components/layout/detached-pane-shell.tsx +185 -0
  77. package/src/components/layout/floating-pane.tsx +87 -13
  78. package/src/components/layout/header.tsx +139 -49
  79. package/src/components/layout/pane-content.tsx +40 -0
  80. package/src/components/layout/pane-footer.test.tsx +195 -0
  81. package/src/components/layout/pane-footer.tsx +410 -0
  82. package/src/components/layout/pane-header.test.tsx +39 -0
  83. package/src/components/layout/pane-header.tsx +192 -14
  84. package/src/components/layout/pane-sizing.ts +13 -4
  85. package/src/components/layout/pane-title.ts +35 -0
  86. package/src/components/layout/pane.tsx +56 -8
  87. package/src/components/layout/shell.test.tsx +322 -19
  88. package/src/components/layout/shell.tsx +1010 -366
  89. package/src/components/layout/status-bar.test.tsx +17 -10
  90. package/src/components/layout/status-bar.tsx +367 -100
  91. package/src/components/layout/titlebar-overlay.ts +2 -0
  92. package/src/components/markdown-editor.tsx +29 -14
  93. package/src/components/markdown-text.tsx +25 -24
  94. package/src/components/onboarding/onboarding-wizard.test.tsx +8 -3
  95. package/src/components/onboarding/onboarding-wizard.tsx +347 -473
  96. package/src/components/pane-settings-dialog.tsx +521 -89
  97. package/src/components/pane-template-wizard.test.tsx +1 -1
  98. package/src/components/pane-template-wizard.tsx +38 -37
  99. package/src/components/price-selector-dialog.tsx +22 -21
  100. package/src/components/ticker-badge-list.tsx +53 -0
  101. package/src/components/ticker-badge-text.test.tsx +44 -1
  102. package/src/components/ticker-badge-text.tsx +78 -38
  103. package/src/components/ticker-badge.tsx +28 -7
  104. package/src/components/ticker-list-table-view.tsx +248 -0
  105. package/src/components/ticker-list-table.test.tsx +47 -1
  106. package/src/components/ticker-list-table.tsx +212 -41
  107. package/src/components/toggle-list.tsx +114 -8
  108. package/src/components/ui/button.tsx +24 -16
  109. package/src/components/ui/choice-dialog.tsx +89 -0
  110. package/src/components/ui/data-table.tsx +313 -114
  111. package/src/components/ui/external-link.tsx +52 -13
  112. package/src/components/ui/fields.tsx +44 -30
  113. package/src/components/ui/frame.tsx +24 -57
  114. package/src/components/ui/index.ts +15 -50
  115. package/src/components/ui/list-view.tsx +65 -25
  116. package/src/components/ui/loading.tsx +5 -60
  117. package/src/components/ui/message-composer.test.tsx +81 -0
  118. package/src/components/ui/message-composer.tsx +139 -0
  119. package/src/components/ui/multi-select-dialog.tsx +100 -32
  120. package/src/components/ui/multi-select.ts +112 -0
  121. package/src/components/ui/native-select.tsx +120 -0
  122. package/src/components/ui/page-stack-view.tsx +49 -19
  123. package/src/components/ui/remote-image.tsx +75 -0
  124. package/src/components/ui/row-value-cache.test.ts +17 -0
  125. package/src/components/ui/row-value-cache.ts +41 -0
  126. package/src/components/ui/shortcut-hint.tsx +55 -0
  127. package/src/components/ui/status.tsx +12 -67
  128. package/src/components/ui/table-layout.ts +59 -0
  129. package/src/components/ui/tabs.tsx +249 -27
  130. package/src/components/ui/toggle.tsx +19 -127
  131. package/src/components/ui/ui.test.tsx +266 -53
  132. package/src/components/use-double-click-activation.ts +7 -2
  133. package/src/components/use-external-link-footer.ts +79 -0
  134. package/src/core/app-services.ts +95 -0
  135. package/src/core/state/app-state.ts +886 -0
  136. package/src/{state → core/state}/session-persistence.ts +7 -6
  137. package/src/data/config-layout.ts +225 -0
  138. package/src/data/config-store-node.ts +390 -0
  139. package/src/data/config-store.test.ts +72 -0
  140. package/src/data/config-store.ts +35 -478
  141. package/src/data/sqlite-json.ts +2 -5
  142. package/src/data/sqlite-retry.ts +1 -0
  143. package/src/index.tsx +2 -68
  144. package/src/market-data/coordinator-subscriptions.test.ts +125 -0
  145. package/src/market-data/coordinator.test.ts +73 -4
  146. package/src/market-data/coordinator.ts +190 -24
  147. package/src/market-data/hooks.test.tsx +1 -1
  148. package/src/market-data/hooks.tsx +109 -87
  149. package/src/market-data/query-store.ts +3 -3
  150. package/src/market-data/request-types.test.ts +0 -14
  151. package/src/market-data/result-types.ts +1 -0
  152. package/src/news/aggregator.test.ts +267 -0
  153. package/src/news/aggregator.ts +452 -0
  154. package/src/news/hooks.ts +39 -0
  155. package/src/news/ticker-symbols.ts +21 -0
  156. package/src/news/types.ts +77 -0
  157. package/src/notifications/app-notifier.ts +21 -10
  158. package/src/pane-settings.test.ts +0 -2
  159. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +124 -134
  160. package/src/plugins/builtin/ai/gloomberb-cli.ts +14 -13
  161. package/src/plugins/builtin/ai/providers.ts +1 -9
  162. package/src/plugins/builtin/ai/runner.ts +41 -5
  163. package/src/plugins/builtin/ai/screener-pane.test.tsx +37 -55
  164. package/src/plugins/builtin/ai/screener-pane.tsx +231 -276
  165. package/src/plugins/builtin/ai/settings.ts +0 -1
  166. package/src/plugins/builtin/ai/utils.ts +1 -71
  167. package/src/plugins/builtin/alerts/alert-engine.test.ts +1 -38
  168. package/src/plugins/builtin/alerts/index.test.tsx +61 -82
  169. package/src/plugins/builtin/alerts/index.tsx +263 -155
  170. package/src/plugins/builtin/alerts/types.ts +7 -0
  171. package/src/plugins/builtin/analytics/index.test.tsx +13 -8
  172. package/src/plugins/builtin/analytics/index.tsx +78 -120
  173. package/src/plugins/builtin/analytics/metrics.test.ts +0 -4
  174. package/src/plugins/builtin/ask-ai.test.tsx +55 -54
  175. package/src/plugins/builtin/broker-manager/index.test.tsx +168 -0
  176. package/src/plugins/builtin/broker-manager/index.tsx +759 -0
  177. package/src/plugins/builtin/broker-manager/model.test.ts +107 -0
  178. package/src/plugins/builtin/broker-manager/model.ts +142 -0
  179. package/src/plugins/builtin/chat-controller.test.ts +150 -0
  180. package/src/plugins/builtin/chat-controller.ts +88 -16
  181. package/src/plugins/builtin/chat.test.tsx +283 -36
  182. package/src/plugins/builtin/chat.tsx +373 -172
  183. package/src/plugins/builtin/cloud-auth-actions.tsx +72 -0
  184. package/src/plugins/builtin/cloud-tweets.tsx +896 -0
  185. package/src/plugins/builtin/comparison-chart.test.tsx +46 -57
  186. package/src/plugins/builtin/comparison-chart.tsx +11 -11
  187. package/src/plugins/builtin/correlation/compute.test.ts +0 -17
  188. package/src/plugins/builtin/correlation/index.tsx +51 -46
  189. package/src/plugins/builtin/correlation/settings.ts +1 -2
  190. package/src/plugins/builtin/debug.tsx +99 -70
  191. package/src/plugins/builtin/earnings/earnings-cache.test.ts +2 -2
  192. package/src/plugins/builtin/earnings/earnings-cache.ts +12 -3
  193. package/src/plugins/builtin/earnings/earnings-data.test.ts +42 -11
  194. package/src/plugins/builtin/earnings/earnings-data.ts +70 -5
  195. package/src/plugins/builtin/earnings/index.tsx +187 -94
  196. package/src/plugins/builtin/econ/calendar-source.ts +9 -116
  197. package/src/plugins/builtin/econ/fred-client.test.ts +1 -14
  198. package/src/plugins/builtin/econ/fred-series-map.ts +1 -0
  199. package/src/plugins/builtin/econ/index.tsx +236 -421
  200. package/src/plugins/builtin/econ/types.ts +1 -0
  201. package/src/plugins/builtin/fx-matrix/index.tsx +43 -45
  202. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -0
  203. package/src/plugins/builtin/help.test.tsx +120 -60
  204. package/src/plugins/builtin/help.tsx +266 -82
  205. package/src/plugins/builtin/holders/index.test.ts +45 -0
  206. package/src/plugins/builtin/holders/index.tsx +949 -0
  207. package/src/plugins/builtin/insider/index.tsx +207 -253
  208. package/src/plugins/builtin/insider/insider-data.test.ts +1 -11
  209. package/src/plugins/builtin/insider/insider-data.ts +1 -0
  210. package/src/plugins/builtin/layout-manager.tsx +12 -40
  211. package/src/plugins/builtin/market-movers/index.tsx +70 -144
  212. package/src/plugins/builtin/market-movers/screener.test.ts +0 -18
  213. package/src/plugins/builtin/news-wire/breaking-notifications.test.ts +98 -0
  214. package/src/plugins/builtin/news-wire/breaking-notifications.ts +110 -0
  215. package/src/plugins/builtin/news-wire/breaking-pane.tsx +157 -0
  216. package/src/plugins/builtin/news-wire/categories.ts +114 -0
  217. package/src/plugins/builtin/news-wire/default-feeds.ts +3 -0
  218. package/src/plugins/builtin/news-wire/digest-store.ts +44 -0
  219. package/src/plugins/builtin/news-wire/feed-config.test.ts +90 -0
  220. package/src/plugins/builtin/news-wire/feed-config.ts +216 -0
  221. package/src/plugins/builtin/news-wire/feed-pane.tsx +21 -0
  222. package/src/plugins/builtin/news-wire/hash.ts +9 -0
  223. package/src/plugins/builtin/news-wire/index.tsx +92 -0
  224. package/src/plugins/builtin/news-wire/industry-pane.tsx +140 -0
  225. package/src/plugins/builtin/news-wire/news-detail-view.tsx +256 -0
  226. package/src/plugins/builtin/news-wire/news-footer.ts +28 -0
  227. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +94 -0
  228. package/src/plugins/builtin/news-wire/news-query-presets.ts +40 -0
  229. package/src/plugins/builtin/news-wire/news-table.test.tsx +206 -0
  230. package/src/plugins/builtin/news-wire/news-table.tsx +296 -0
  231. package/src/plugins/builtin/news-wire/persisted-articles.ts +92 -0
  232. package/src/plugins/builtin/news-wire/read-state.test.ts +30 -0
  233. package/src/plugins/builtin/news-wire/read-state.ts +76 -0
  234. package/src/plugins/builtin/news-wire/rss-parser.test.ts +184 -0
  235. package/src/plugins/builtin/news-wire/rss-parser.ts +213 -0
  236. package/src/plugins/builtin/news-wire/rss-source.test.ts +159 -0
  237. package/src/plugins/builtin/news-wire/rss-source.ts +181 -0
  238. package/src/plugins/builtin/news-wire/top-pane.tsx +21 -0
  239. package/src/plugins/builtin/news.tsx +98 -25
  240. package/src/plugins/builtin/notes-files.ts +51 -10
  241. package/src/plugins/builtin/notes.tsx +332 -131
  242. package/src/plugins/builtin/options.test.tsx +185 -0
  243. package/src/plugins/builtin/options.tsx +346 -206
  244. package/src/plugins/builtin/plugin-groups.ts +92 -0
  245. package/src/plugins/builtin/portfolio-list/header.tsx +72 -37
  246. package/src/plugins/builtin/portfolio-list/index.tsx +25 -42
  247. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  248. package/src/plugins/builtin/portfolio-list/pane.tsx +246 -164
  249. package/src/plugins/builtin/portfolio-list/settings.ts +19 -51
  250. package/src/plugins/builtin/portfolio-list/summary.tsx +21 -16
  251. package/src/plugins/builtin/portfolio-list/table.tsx +66 -25
  252. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +1 -1
  253. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +1 -1
  254. package/src/plugins/builtin/portfolio-list.test.ts +60 -1
  255. package/src/plugins/builtin/portfolio-list.test.tsx +122 -55
  256. package/src/plugins/builtin/quote-monitor.test.tsx +2 -27
  257. package/src/plugins/builtin/research/index.tsx +724 -0
  258. package/src/plugins/builtin/sec.tsx +67 -19
  259. package/src/plugins/builtin/sectors/index.tsx +42 -80
  260. package/src/plugins/builtin/sectors/sector-data.ts +1 -0
  261. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +25 -39
  262. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +108 -58
  263. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +383 -271
  264. package/src/plugins/builtin/ticker-detail/pane.tsx +135 -49
  265. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +43 -27
  266. package/src/plugins/builtin/ticker-detail/settings.ts +3 -6
  267. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +16 -10
  268. package/src/plugins/builtin/ticker-detail.test.tsx +156 -33
  269. package/src/plugins/builtin/ticker-surface.ts +68 -0
  270. package/src/plugins/builtin/world-indices/index.tsx +44 -82
  271. package/src/plugins/builtin/world-indices/indices.ts +1 -0
  272. package/src/plugins/builtin/yahoo.ts +55 -1
  273. package/src/plugins/builtin/yield-curve/index.tsx +87 -145
  274. package/src/plugins/builtin/yield-curve/treasury-data.ts +6 -77
  275. package/src/plugins/catalog-ui.test.ts +24 -0
  276. package/src/plugins/catalog-ui.ts +44 -0
  277. package/src/plugins/catalog.ts +2 -50
  278. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  279. package/src/plugins/ibkr/account-cache.test.ts +15 -14
  280. package/src/plugins/ibkr/account-cache.ts +15 -120
  281. package/src/plugins/ibkr/broker-adapter.ts +67 -0
  282. package/src/plugins/ibkr/config.test.ts +0 -18
  283. package/src/plugins/ibkr/dialogs.tsx +14 -56
  284. package/src/plugins/ibkr/flex.test.ts +57 -2
  285. package/src/plugins/ibkr/flex.ts +116 -12
  286. package/src/plugins/ibkr/gateway-helpers.ts +8 -3
  287. package/src/plugins/ibkr/gateway-service-native.ts +2381 -0
  288. package/src/plugins/ibkr/gateway-service.test.ts +47 -9
  289. package/src/plugins/ibkr/gateway-service.ts +264 -2235
  290. package/src/plugins/ibkr/gateway-types.ts +27 -0
  291. package/src/plugins/ibkr/index.test.tsx +3 -11
  292. package/src/plugins/ibkr/index.tsx +1 -0
  293. package/src/plugins/ibkr/price-normalization.test.ts +1 -6
  294. package/src/plugins/ibkr/price-normalization.ts +1 -0
  295. package/src/plugins/ibkr/trade-badge.tsx +6 -5
  296. package/src/plugins/ibkr/trade-tab.test.tsx +2 -2
  297. package/src/plugins/ibkr/trade-tab.tsx +164 -117
  298. package/src/plugins/ibkr/trading-pane.tsx +117 -83
  299. package/src/plugins/pane-manager.test.ts +3 -1
  300. package/src/plugins/pane-manager.ts +93 -19
  301. package/src/plugins/plugin-runtime.test.tsx +176 -74
  302. package/src/plugins/plugin-runtime.tsx +228 -10
  303. package/src/plugins/prediction-markets/cache.ts +49 -0
  304. package/src/plugins/prediction-markets/categories.ts +0 -10
  305. package/src/plugins/prediction-markets/chart.test.tsx +1 -1
  306. package/src/plugins/prediction-markets/chart.tsx +21 -20
  307. package/src/plugins/prediction-markets/cli.ts +1 -0
  308. package/src/plugins/prediction-markets/controller-data.ts +135 -89
  309. package/src/plugins/prediction-markets/controller.ts +37 -108
  310. package/src/plugins/prediction-markets/detail/book.tsx +23 -22
  311. package/src/plugins/prediction-markets/detail/outcomes.tsx +33 -32
  312. package/src/plugins/prediction-markets/detail/overview.tsx +17 -12
  313. package/src/plugins/prediction-markets/detail/pane.tsx +59 -45
  314. package/src/plugins/prediction-markets/detail/rules.tsx +22 -7
  315. package/src/plugins/prediction-markets/detail/shared.tsx +3 -2
  316. package/src/plugins/prediction-markets/detail/trades.tsx +12 -11
  317. package/src/plugins/prediction-markets/detail.test.tsx +1 -1
  318. package/src/plugins/prediction-markets/keyboard.ts +1 -0
  319. package/src/plugins/prediction-markets/launch.test.ts +1 -1
  320. package/src/plugins/prediction-markets/launch.ts +1 -1
  321. package/src/plugins/prediction-markets/metrics.ts +25 -12
  322. package/src/plugins/prediction-markets/pane.test.tsx +16 -7
  323. package/src/plugins/prediction-markets/pane.tsx +180 -82
  324. package/src/plugins/prediction-markets/plugin.test.tsx +37 -45
  325. package/src/plugins/prediction-markets/services/fetch.ts +27 -10
  326. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +20 -3
  327. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  328. package/src/plugins/prediction-markets/settings.ts +0 -5
  329. package/src/plugins/prediction-markets/test-helpers.tsx +19 -44
  330. package/src/plugins/registry.test.ts +307 -0
  331. package/src/plugins/registry.ts +293 -82
  332. package/src/react/input.ts +53 -0
  333. package/src/react/plugins/plugin-slot.tsx +21 -0
  334. package/src/react/use-raf-callback.ts +44 -0
  335. package/src/renderers/electrobun/bun/application-menu-click.test.ts +41 -0
  336. package/src/renderers/electrobun/bun/application-menu-click.ts +55 -0
  337. package/src/renderers/electrobun/bun/application-menu.ts +118 -0
  338. package/src/renderers/electrobun/bun/context-menu-click.test.ts +67 -0
  339. package/src/renderers/electrobun/bun/context-menu-click.ts +61 -0
  340. package/src/renderers/electrobun/bun/context-menu-normalize.test.ts +52 -0
  341. package/src/renderers/electrobun/bun/context-menu-normalize.ts +67 -0
  342. package/src/renderers/electrobun/bun/core-capabilities.ts +328 -0
  343. package/src/renderers/electrobun/bun/desktop-workspace.test.ts +63 -0
  344. package/src/renderers/electrobun/bun/desktop-workspace.ts +162 -0
  345. package/src/renderers/electrobun/bun/index.ts +1149 -0
  346. package/src/renderers/electrobun/bun/window-frame.test.ts +40 -0
  347. package/src/renderers/electrobun/bun/window-frame.ts +46 -0
  348. package/src/renderers/electrobun/shared/protocol.ts +72 -0
  349. package/src/renderers/electrobun/view/ai-host.ts +93 -0
  350. package/src/renderers/electrobun/view/app-services.ts +348 -0
  351. package/src/renderers/electrobun/view/application-menu-bridge.ts +12 -0
  352. package/src/renderers/electrobun/view/backend-rpc.ts +173 -0
  353. package/src/renderers/electrobun/view/broker-remote-client.ts +154 -0
  354. package/src/renderers/electrobun/view/config-host.ts +35 -0
  355. package/src/renderers/electrobun/view/context-menu.test.ts +140 -0
  356. package/src/renderers/electrobun/view/context-menu.ts +164 -0
  357. package/src/renderers/electrobun/view/data-table.tsx +773 -0
  358. package/src/renderers/electrobun/view/desktop-controls.tsx +651 -0
  359. package/src/renderers/electrobun/view/desktop-window-bridge.ts +59 -0
  360. package/src/renderers/electrobun/view/desktop-window-snapshot.test.ts +57 -0
  361. package/src/renderers/electrobun/view/desktop-window-snapshot.ts +41 -0
  362. package/src/renderers/electrobun/view/dialog-host.tsx +62 -0
  363. package/src/renderers/electrobun/view/http-fetch.ts +113 -0
  364. package/src/renderers/electrobun/view/input-host.tsx +65 -0
  365. package/src/renderers/electrobun/view/key-event.test.ts +34 -0
  366. package/src/renderers/electrobun/view/key-event.ts +134 -0
  367. package/src/renderers/electrobun/view/main.tsx +104 -0
  368. package/src/renderers/electrobun/view/native-renderer.ts +110 -0
  369. package/src/renderers/electrobun/view/native-stubs/chart-kitty-support.ts +9 -0
  370. package/src/renderers/electrobun/view/native-stubs/chart-surface-manager.ts +25 -0
  371. package/src/renderers/electrobun/view/native-stubs/chart-surface-sync.ts +1 -0
  372. package/src/renderers/electrobun/view/notes-files.ts +56 -0
  373. package/src/renderers/electrobun/view/resource-store.test.ts +44 -0
  374. package/src/renderers/electrobun/view/resource-store.ts +106 -0
  375. package/src/renderers/electrobun/view/rpc-codec.ts +42 -0
  376. package/src/renderers/electrobun/view/scrollbar-activity.ts +23 -0
  377. package/src/renderers/electrobun/view/toast-host.tsx +65 -0
  378. package/src/renderers/electrobun/view/ui-host.tsx +1490 -0
  379. package/src/renderers/electrobun/view/update-host.ts +38 -0
  380. package/src/renderers/opentui/dialog-host.test.tsx +44 -0
  381. package/src/renderers/opentui/dialog-host.tsx +56 -0
  382. package/src/renderers/opentui/host.tsx +134 -0
  383. package/src/renderers/opentui/input-host.tsx +24 -0
  384. package/src/renderers/opentui/start.tsx +71 -0
  385. package/src/renderers/opentui/test-utils.tsx +76 -0
  386. package/src/renderers/opentui/toast-host.tsx +10 -0
  387. package/src/renderers/opentui/ui-host.tsx +97 -0
  388. package/src/sources/gloomberb-cloud.test.ts +288 -17
  389. package/src/sources/gloomberb-cloud.ts +176 -9
  390. package/src/sources/provider-errors.ts +1 -0
  391. package/src/sources/provider-router.test.ts +220 -81
  392. package/src/sources/provider-router.ts +294 -111
  393. package/src/sources/sec-edgar.ts +19 -6
  394. package/src/sources/yahoo-finance.test.ts +0 -5
  395. package/src/sources/yahoo-finance.ts +420 -11
  396. package/src/state/app-activity.ts +7 -7
  397. package/src/state/app-bootstrap.ts +114 -40
  398. package/src/state/app-context-selector.test.tsx +70 -1
  399. package/src/state/app-context.test.ts +71 -3
  400. package/src/state/app-context.test.tsx +14 -7
  401. package/src/state/app-context.tsx +205 -869
  402. package/src/state/config-save-scheduler.ts +35 -0
  403. package/src/state/persist-scheduler.test.ts +67 -0
  404. package/src/state/persist-scheduler.ts +69 -0
  405. package/src/state/selectors-ui.ts +55 -0
  406. package/src/state/selectors.ts +15 -71
  407. package/src/state/session-persistence.test.ts +1 -1
  408. package/src/state/ticker-refresh-queue.ts +52 -3
  409. package/src/state/use-inline-tickers.ts +18 -16
  410. package/src/state/use-quote-streaming.test.ts +0 -6
  411. package/src/state/use-quote-streaming.test.tsx +1 -1
  412. package/src/state/use-quote-streaming.ts +3 -2
  413. package/src/state/use-session-snapshot.ts +32 -17
  414. package/src/test-support/plugin-runtime.ts +149 -0
  415. package/src/theme/color-utils.ts +1 -0
  416. package/src/theme/colors.ts +16 -0
  417. package/src/types/broker.ts +16 -1
  418. package/src/types/capability-route-source.ts +23 -0
  419. package/src/types/config.test.ts +23 -0
  420. package/src/types/config.ts +145 -10
  421. package/src/types/context-menu.ts +95 -0
  422. package/src/types/data-provider.ts +34 -3
  423. package/src/types/desktop-menu.ts +13 -0
  424. package/src/types/desktop-window.ts +29 -0
  425. package/src/types/financials.ts +123 -2
  426. package/src/types/instrument.ts +1 -0
  427. package/src/types/news-source.ts +9 -0
  428. package/src/types/persistence.ts +1 -0
  429. package/src/types/plugin.ts +32 -8
  430. package/src/ui/ascii-font.ts +85 -0
  431. package/src/ui/context-menu.test.ts +94 -0
  432. package/src/ui/context-menu.tsx +433 -0
  433. package/src/ui/dialog.tsx +55 -0
  434. package/src/ui/host.tsx +361 -0
  435. package/src/ui/index.tsx +126 -0
  436. package/src/ui/toast.tsx +40 -0
  437. package/src/updater.test.ts +69 -0
  438. package/src/updater.ts +61 -18
  439. package/src/utils/api-client.test.ts +186 -6
  440. package/src/utils/api-client.ts +433 -33
  441. package/src/utils/back-navigation.ts +6 -0
  442. package/src/utils/cli-output.ts +1 -0
  443. package/src/utils/currency-units.test.ts +1 -6
  444. package/src/utils/currency-units.ts +1 -0
  445. package/src/utils/debug-log.ts +68 -0
  446. package/src/utils/dom-screenshot.ts +326 -0
  447. package/src/utils/exchange-rate-map.ts +1 -0
  448. package/src/utils/exchanges.ts +214 -0
  449. package/src/utils/format.test.ts +7 -0
  450. package/src/utils/format.ts +1 -0
  451. package/src/utils/fuzzy-search.ts +1 -0
  452. package/src/utils/html-entities.test.ts +8 -0
  453. package/src/utils/html-entities.ts +32 -0
  454. package/src/utils/http-transport.ts +11 -0
  455. package/src/utils/main-thread-monitor.ts +127 -0
  456. package/src/utils/perf-marks.ts +54 -0
  457. package/src/utils/quote-freshness.ts +27 -50
  458. package/src/utils/quote-resolution.test.ts +5 -5
  459. package/src/utils/quote-resolution.ts +0 -1
  460. package/src/utils/quote-time.ts +1 -5
  461. package/src/utils/sec.test.ts +0 -12
  462. package/src/utils/selection-clipboard.ts +14 -6
  463. package/src/utils/shortcut-labels.test.ts +20 -0
  464. package/src/utils/shortcut-labels.ts +67 -0
  465. package/src/utils/slugify.ts +1 -0
  466. package/src/utils/terminal-input-reset.ts +1 -0
  467. package/src/utils/text-wrap.ts +82 -0
  468. package/src/utils/throttled-fetch.test.ts +19 -0
  469. package/src/utils/throttled-fetch.ts +11 -9
  470. package/src/utils/ticker-list.test.ts +1 -7
  471. package/src/utils/ticker-list.ts +1 -0
  472. package/src/utils/timestamp.ts +29 -0
  473. package/src/utils/tweet-text.test.ts +13 -0
  474. package/src/utils/tweet-text.ts +18 -0
  475. package/src/utils/url.ts +11 -0
  476. package/src/version.ts +1 -1
  477. package/src/components/chart/chart-control-hint.tsx +0 -39
  478. package/src/components/detail-feed-view.test.tsx +0 -180
  479. package/src/components/detail-feed-view.tsx +0 -531
  480. package/src/components/layout/pixel-mouse-mode.test.ts +0 -122
  481. package/src/components/layout/pixel-mouse.test.ts +0 -40
  482. package/src/components/spinner.tsx +0 -2
  483. package/src/components/tab-bar.tsx +0 -17
  484. package/src/components/ui/multi-select-chips.tsx +0 -69
  485. package/src/plugins/builtin/ai/index.test.tsx +0 -139
  486. package/src/plugins/builtin/ask-ai.tsx +0 -9
  487. package/src/plugins/builtin/correlation/plugin.test.ts +0 -44
  488. package/src/plugins/builtin/correlation/settings.test.ts +0 -34
  489. package/src/plugins/builtin/econ/calendar-source.test.ts +0 -219
  490. package/src/plugins/builtin/econ/fred-client.ts +0 -79
  491. package/src/plugins/builtin/fred-settings.ts +0 -10
  492. package/src/plugins/builtin/options-availability.test.tsx +0 -179
  493. package/src/plugins/builtin/options-availability.ts +0 -158
  494. package/src/plugins/builtin/portfolio-list/metrics.test.ts +0 -61
  495. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +0 -212
  496. package/src/plugins/prediction-markets/status.ts +0 -24
  497. package/src/plugins/prediction-markets/table.tsx +0 -87
  498. package/src/utils/quote-time.test.ts +0 -22
  499. package/src/utils/selection-clipboard.test.ts +0 -43
  500. package/src/utils/terminal-input-reset.test.ts +0 -50
package/src/app.tsx CHANGED
@@ -1,32 +1,34 @@
1
+ import { Box, ContextMenuProvider, useNativeRenderer, useRendererHost } from "./ui";
2
+ import { ToastViewport, useToastHost } from "./ui/toast";
1
3
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
- import { useKeyboard, useRenderer } from "@opentui/react";
3
- import type { CliRenderer } from "@opentui/core";
4
+ import { useShortcut } from "./react/input";
4
5
  import {
5
6
  AppProvider,
6
7
  getFocusedCollectionId,
7
8
  getFocusedTickerSymbol,
8
- resolveCollectionForPane,
9
- resolveTickerForPane,
10
- useAppState,
9
+ useAppDispatch,
10
+ useAppSelector,
11
+ useAppStateRef,
12
+ type AppState,
11
13
  } from "./state/app-context";
12
14
  import { bindAppActivity, useAppActive } from "./state/app-activity";
13
15
  import { copyActiveSelection, isCopyShortcut, isPasteShortcut, pasteSystemClipboard } from "./utils/selection-clipboard";
14
16
  import { Header } from "./components/layout/header";
15
17
  import { StatusBar } from "./components/layout/status-bar";
16
18
  import { Shell } from "./components/layout/shell";
19
+ import { DetachedPaneShell } from "./components/layout/detached-pane-shell";
17
20
  import { CommandBar } from "./components/command-bar/command-bar";
18
21
  import { OnboardingWizard } from "./components/onboarding/onboarding-wizard";
19
- import { DialogProvider, useDialog, useDialogState } from "@opentui-ui/dialog/react";
22
+ import { useDialog, useDialogState } from "./ui/dialog";
20
23
  import { PluginRegistry } from "./plugins/registry";
21
- import { AppPersistence } from "./data/app-persistence";
22
- import { TickerRepository } from "./data/ticker-repository";
23
- import { ProviderRouter } from "./sources/provider-router";
24
+ import type { TickerRepository } from "./data/ticker-repository";
24
25
  import { colors, syncTheme } from "./theme/colors";
25
26
  import {
26
27
  createPaneInstance,
27
28
  findPaneInstance,
28
29
  findPrimaryPaneInstance,
29
30
  isTickerPaneId,
31
+ materializeDetachedPanesAsFloating,
30
32
  normalizePaneLayout,
31
33
  resolveFollowBindingInstance,
32
34
  resolvePaneInstance,
@@ -42,22 +44,19 @@ import type { TickerRecord } from "./types/ticker";
42
44
  import type { DataProvider } from "./types/data-provider";
43
45
  import type { TickerFinancials } from "./types/financials";
44
46
  import type { BrokerAccount } from "./types/trading";
47
+ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopWindowBridge } from "./types/desktop-window";
48
+ import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
45
49
  import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
46
50
 
47
- // Built-in plugins
48
51
  import {
49
- clearPersistedIbkrAccounts,
50
- loadPersistedIbkrAccountMap,
51
- } from "./plugins/ibkr/account-cache";
52
- import { getIbkrConfigIdentity } from "./plugins/ibkr/config";
52
+ clearPersistedBrokerAccounts,
53
+ getBrokerAccountCacheSourceKey,
54
+ loadPersistedBrokerAccountMap,
55
+ } from "./brokers/account-cache";
53
56
  import { chatController } from "./plugins/builtin/chat-controller";
54
57
  import { setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
55
- import { getLoadablePlugins } from "./plugins/catalog";
56
- import { saveConfig } from "./data/config-store";
57
- import { Toaster, toast } from "@opentui-ui/toast/react";
58
58
  import { canSelfUpdate, checkForUpdateDetailed, performUpdate, type ReleaseInfo } from "./updater";
59
59
  import { VERSION } from "./version";
60
- import { join } from "path";
61
60
  import {
62
61
  addPaneFloating,
63
62
  addPaneToLayout,
@@ -66,9 +65,11 @@ import {
66
65
  getDockLeafLayouts,
67
66
  getDockedPaneIds,
68
67
  getLeafRect,
68
+ gridlockAllPanes,
69
69
  isPaneInLayout,
70
70
  removePane,
71
71
  } from "./plugins/pane-manager";
72
+ import { notifyGridlockComplete } from "./plugins/gridlock-notification";
72
73
  import {
73
74
  createBrokerInstanceId,
74
75
  getBrokerInstance,
@@ -78,7 +79,7 @@ import {
78
79
  APP_SESSION_SCHEMA_VERSION,
79
80
  reconcileAppSessionSnapshot,
80
81
  type AppSessionSnapshot,
81
- } from "./state/session-persistence";
82
+ } from "./core/state/session-persistence";
82
83
  import { initializeAppState } from "./state/app-bootstrap";
83
84
  import { TickerRefreshQueue } from "./state/ticker-refresh-queue";
84
85
  import { PaneSettingsDialogContent } from "./components/pane-settings-dialog";
@@ -94,10 +95,21 @@ import {
94
95
  } from "./components/command-bar/workflow-ops";
95
96
  import { getPaneTemplateDisplayLabel } from "./components/command-bar/pane-template-display";
96
97
  import { debugLog } from "./utils/debug-log";
97
- import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "./market-data/coordinator";
98
+ import type { MarketDataCoordinator } from "./market-data/coordinator";
98
99
  import { instrumentFromTicker } from "./market-data/request-types";
99
- import { syncBrokerInstance } from "./brokers/sync-broker-instance";
100
+ import {
101
+ restoreBrokerPortfoliosFromTickerPositions,
102
+ syncBrokerInstance,
103
+ syncBrokerInstances,
104
+ type SyncBrokerInstanceResult,
105
+ } from "./brokers/sync-broker-instance";
100
106
  import { createAppNotifier } from "./notifications/app-notifier";
107
+ import { createAppServices } from "./core/app-services";
108
+ import {
109
+ saveConfigImmediately,
110
+ scheduleConfigSave,
111
+ } from "./state/config-save-scheduler";
112
+ import { measurePerf, measurePerfAsync } from "./utils/perf-marks";
101
113
 
102
114
  /** Global-level dedup: prevents concurrent refresh calls for the same symbol. */
103
115
  const refreshInFlight: Set<string> = (globalThis as any).__refreshInFlight ??= new Set<string>();
@@ -113,35 +125,72 @@ function isTickerContextPaneInstance(instance: PaneInstanceConfig): boolean {
113
125
  return instance.paneId === "portfolio-list" || isTickerPaneId(instance.paneId);
114
126
  }
115
127
 
116
- function summarizeError(error: unknown): Record<string, string> {
117
- if (error instanceof Error) {
118
- return {
119
- name: error.name,
120
- message: error.message,
121
- stack: error.stack || "",
122
- };
123
- }
124
- return { message: String(error) };
125
- }
126
-
127
128
  interface AppInnerProps {
128
129
  pluginRegistry: PluginRegistry;
129
130
  tickerRepository: TickerRepository;
130
131
  dataProvider: DataProvider;
131
132
  marketData: MarketDataCoordinator;
132
133
  sessionSnapshot?: AppSessionSnapshot | null;
134
+ desktopWindowBridge?: DesktopWindowBridge;
135
+ desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
133
136
  }
134
137
 
135
- function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData, sessionSnapshot = null }: AppInnerProps) {
136
- const { state, dispatch } = useAppState();
138
+ function AppInner({
139
+ pluginRegistry,
140
+ tickerRepository,
141
+ dataProvider,
142
+ marketData,
143
+ sessionSnapshot = null,
144
+ desktopWindowBridge,
145
+ desktopApplicationMenuBridge,
146
+ }: AppInnerProps) {
147
+ const dispatch = useAppDispatch();
148
+ const stateRef = useAppStateRef();
149
+ const config = useAppSelector((state) => state.config);
150
+ const tickers = useAppSelector((state) => state.tickers);
151
+ const paneState = useAppSelector((state) => state.paneState);
152
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
153
+ const initialized = useAppSelector((state) => state.initialized);
154
+ const commandBarOpen = useAppSelector((state) => state.commandBarOpen);
155
+ const inputCaptured = useAppSelector((state) => state.inputCaptured);
156
+ const updateAvailable = useAppSelector((state) => state.updateAvailable);
157
+ const updateProgress = useAppSelector((state) => state.updateProgress);
158
+ const updateCheckInProgress = useAppSelector((state) => state.updateCheckInProgress);
159
+ const state = useMemo(() => ({
160
+ ...stateRef.current,
161
+ config,
162
+ tickers,
163
+ paneState,
164
+ focusedPaneId,
165
+ initialized,
166
+ commandBarOpen,
167
+ inputCaptured,
168
+ updateAvailable,
169
+ updateProgress,
170
+ updateCheckInProgress,
171
+ }) as AppState, [
172
+ commandBarOpen,
173
+ config,
174
+ focusedPaneId,
175
+ initialized,
176
+ inputCaptured,
177
+ paneState,
178
+ stateRef,
179
+ tickers,
180
+ updateAvailable,
181
+ updateCheckInProgress,
182
+ updateProgress,
183
+ ]);
137
184
  const appActive = useAppActive();
138
185
  const appActiveRef = useRef(appActive);
139
- const renderer = useRenderer();
186
+ const rendererHost = useRendererHost();
187
+ const nativeRenderer = useNativeRenderer();
140
188
  const dialog = useDialog();
141
- const stateRef = useRef(state);
142
- stateRef.current = state;
189
+ const toast = useToastHost();
190
+ const isDetachedWindow = desktopWindowBridge?.kind === "detached";
191
+ const detachedPaneId = isDetachedWindow ? desktopWindowBridge.paneId ?? null : null;
192
+ const [desktopDockPreview, setDesktopDockPreview] = useState<DesktopDockPreviewState | null>(null);
143
193
  appActiveRef.current = appActive;
144
- const focusedCollectionId = getFocusedCollectionId(state);
145
194
  const appNotifier = useMemo(() => createAppNotifier({
146
195
  isAppActive: () => appActiveRef.current,
147
196
  renderToast: (notification) => {
@@ -166,27 +215,23 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
166
215
  else if (type === "error") toastId = toast.error(notification.body, options);
167
216
  else toastId = toast.info(notification.body, options);
168
217
  },
169
- }), []);
218
+ desktop: rendererHost.supportsNativeDesktopNotifications ? rendererHost : undefined,
219
+ }), [rendererHost, toast]);
170
220
  const notify = useCallback((body: string, options?: { type?: "info" | "success" | "error" }) => {
171
221
  pluginRegistry.notify({ body, ...options });
172
222
  }, [pluginRegistry]);
173
223
 
224
+ useEffect(() => {
225
+ if (desktopWindowBridge?.kind !== "main" || !desktopWindowBridge.subscribeDockPreview) return;
226
+ return desktopWindowBridge.subscribeDockPreview((preview) => {
227
+ setDesktopDockPreview(preview);
228
+ });
229
+ }, [desktopWindowBridge]);
230
+
174
231
  const resolvePaneTarget = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): string | null => {
175
232
  return resolvePaneInstance(layout, paneId)?.instanceId ?? null;
176
233
  }, [state.config.layout]);
177
234
 
178
- const getPreferredPortfolio = useCallback((ticker: TickerRecord | null) => {
179
- const focusedPortfolio = state.config.portfolios.find((portfolio) => portfolio.id === focusedCollectionId);
180
- if (focusedPortfolio) return focusedPortfolio;
181
- if (ticker) {
182
- for (const portfolioId of ticker.metadata.portfolios) {
183
- const portfolio = state.config.portfolios.find((entry) => entry.id === portfolioId);
184
- if (portfolio) return portfolio;
185
- }
186
- }
187
- return state.config.portfolios[0] ?? null;
188
- }, [focusedCollectionId, state.config.portfolios]);
189
-
190
235
  const runPaneTemplateWizard = useCallback(async (steps: WizardStep[]): Promise<Record<string, string> | null> => {
191
236
  const values: Record<string, string> = {};
192
237
 
@@ -293,7 +338,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
293
338
  index === state.config.activeLayoutIndex ? { ...savedLayout, layout: ensured.layout } : savedLayout
294
339
  ));
295
340
  dispatch({ type: "UPDATE_LAYOUT", layout: ensured.layout });
296
- saveConfig({ ...state.config, layout: ensured.layout, layouts }).catch(() => {});
341
+ scheduleConfigSave({ ...state.config, layout: ensured.layout, layouts });
297
342
  }
298
343
  return ensured.instance.instanceId;
299
344
  })();
@@ -474,26 +519,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
474
519
  marketData.primeCachedFinancials(primeEntries);
475
520
  }, [marketData]);
476
521
 
477
- // Import positions from a single broker instance
478
- const importBrokerPositions = useCallback(async (
522
+ const applyBrokerImportResult = useCallback(async (
479
523
  instanceId: string,
480
- tickerMap?: Map<string, TickerRecord>,
524
+ result: SyncBrokerInstanceResult,
525
+ baseConfig: AppConfig,
481
526
  options?: { refreshImportedTickers?: boolean },
482
527
  ) => {
483
- const result = await syncBrokerInstance({
484
- config: state.config,
485
- instanceId,
486
- brokers: pluginRegistry.brokers,
487
- tickerRepository,
488
- existingTickers: tickerMap ?? new Map(state.tickers),
489
- resources: pluginRegistry.persistence.resources,
490
- });
491
-
492
528
  dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts: result.brokerAccounts });
493
529
 
494
- if (result.config !== state.config) {
530
+ if (result.config !== baseConfig) {
495
531
  dispatch({ type: "SET_CONFIG", config: result.config });
496
- await saveConfig(result.config);
532
+ await saveConfigImmediately(result.config);
497
533
  pluginRegistry.events.emit("config:changed", { config: result.config });
498
534
  }
499
535
 
@@ -505,31 +541,57 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
505
541
  }
506
542
 
507
543
  for (const position of result.positions) {
508
- // Skip Yahoo Finance for options — IBKR symbols aren't resolvable there.
544
+ // Skip Yahoo Finance for broker option symbols; position marks are already available.
509
545
  // Position data (markPrice, marketValue, unrealizedPnl) is used directly.
510
546
  if (options?.refreshImportedTickers !== false && position.assetCategory !== "OPT") {
511
547
  refreshQuote(position.ticker, position.exchange, undefined, 1);
512
548
  }
513
549
  }
550
+ }, [dispatch, pluginRegistry.events, refreshQuote]);
551
+
552
+ // Import positions from a single broker instance
553
+ const importBrokerPositions = useCallback(async (
554
+ instanceId: string,
555
+ tickerMap?: Map<string, TickerRecord>,
556
+ options?: { refreshImportedTickers?: boolean; config?: AppConfig },
557
+ ) => {
558
+ const baseConfig = options?.config ?? stateRef.current.config;
559
+ const result = await syncBrokerInstance({
560
+ config: baseConfig,
561
+ instanceId,
562
+ brokers: pluginRegistry.brokers,
563
+ tickerRepository,
564
+ existingTickers: tickerMap ?? new Map(stateRef.current.tickers),
565
+ resources: pluginRegistry.persistence.resources,
566
+ });
567
+
568
+ await applyBrokerImportResult(instanceId, result, baseConfig, options);
514
569
 
515
570
  return result;
516
- }, [dispatch, pluginRegistry.brokers, pluginRegistry.events, pluginRegistry.persistence.resources, refreshQuote, state.config, state.tickers, tickerRepository]);
571
+ }, [applyBrokerImportResult, pluginRegistry.brokers, pluginRegistry.persistence.resources, stateRef, tickerRepository]);
517
572
 
518
573
  // Auto-import positions from all configured broker instances
519
574
  const autoImportBrokerPositions = useCallback(async (tickerMap: Map<string, TickerRecord>) => {
520
- let nextTickerMap = tickerMap;
521
- for (const instance of state.config.brokerInstances) {
522
- if (instance.enabled === false) continue;
523
- try {
524
- const result = await importBrokerPositions(instance.id, nextTickerMap, { refreshImportedTickers: false });
525
- if (result) {
526
- nextTickerMap = result.tickers;
527
- }
528
- } catch {
529
- // Silently fail — broker import is best-effort on startup
530
- }
575
+ const restoredConfig = restoreBrokerPortfoliosFromTickerPositions(stateRef.current.config, tickerMap.values());
576
+ if (restoredConfig !== stateRef.current.config) {
577
+ dispatch({ type: "SET_CONFIG", config: restoredConfig });
578
+ await saveConfigImmediately(restoredConfig);
579
+ pluginRegistry.events.emit("config:changed", { config: restoredConfig });
531
580
  }
532
- }, [state.config.brokerInstances, importBrokerPositions]);
581
+
582
+ await syncBrokerInstances({
583
+ config: restoredConfig,
584
+ brokers: pluginRegistry.brokers,
585
+ tickerRepository,
586
+ existingTickers: tickerMap,
587
+ resources: pluginRegistry.persistence.resources,
588
+ onResult: async (result, instance, previousConfig) => {
589
+ await applyBrokerImportResult(instance.id, result, previousConfig, {
590
+ refreshImportedTickers: false,
591
+ });
592
+ },
593
+ });
594
+ }, [applyBrokerImportResult, dispatch, pluginRegistry.brokers, pluginRegistry.events, pluginRegistry.persistence.resources, stateRef, tickerRepository]);
533
595
 
534
596
  const startUpdate = useCallback((release: ReleaseInfo) => {
535
597
  dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
@@ -576,14 +638,64 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
576
638
 
577
639
  // Check for updates on mount
578
640
  useEffect(() => {
641
+ if (isDetachedWindow) return;
579
642
  void runUpdateCheck(false);
580
- }, [runUpdateCheck]);
643
+ }, [isDetachedWindow, runUpdateCheck]);
644
+
645
+ useEffect(() => {
646
+ if (desktopWindowBridge?.kind !== "main" || !desktopApplicationMenuBridge) return;
647
+ return desktopApplicationMenuBridge.subscribe((command) => {
648
+ switch (command.type) {
649
+ case "open-command-bar":
650
+ dispatch({ type: "SET_COMMAND_BAR", open: true, query: command.query });
651
+ break;
652
+ case "open-plugin-workflow":
653
+ pluginRegistry.openPluginCommandWorkflowFn(command.commandId);
654
+ break;
655
+ case "open-url":
656
+ void rendererHost.openExternal(command.url).catch((error) => {
657
+ const message = error instanceof Error ? error.message : String(error);
658
+ pluginRegistry.notify({ body: `Failed to open link: ${message}`, type: "error" });
659
+ });
660
+ break;
661
+ case "check-for-updates":
662
+ void runUpdateCheck(true);
663
+ break;
664
+ case "toggle-status-bar":
665
+ dispatch({ type: "TOGGLE_STATUS_BAR" });
666
+ break;
667
+ case "layout-undo":
668
+ dispatch({ type: "UNDO_LAYOUT" });
669
+ break;
670
+ case "layout-redo":
671
+ dispatch({ type: "REDO_LAYOUT" });
672
+ break;
673
+ case "layout-gridlock": {
674
+ const { width, height } = pluginRegistry.getTermSizeFn();
675
+ pluginRegistry.updateLayoutFn(gridlockAllPanes(stateRef.current.config.layout, { x: 0, y: 0, width, height }));
676
+ notifyGridlockComplete(pluginRegistry.notify.bind(pluginRegistry), () => {
677
+ dispatch({ type: "UNDO_LAYOUT" });
678
+ });
679
+ break;
680
+ }
681
+ }
682
+ });
683
+ }, [
684
+ desktopApplicationMenuBridge,
685
+ desktopWindowBridge?.kind,
686
+ dispatch,
687
+ pluginRegistry,
688
+ rendererHost,
689
+ runUpdateCheck,
690
+ stateRef,
691
+ ]);
581
692
 
582
693
  useEffect(() => {
694
+ if (isDetachedWindow) return;
583
695
  if (!state.updateAvailable || state.updateProgress || state.updateCheckInProgress) return;
584
696
  if (!canSelfUpdate(state.updateAvailable)) return;
585
697
  startUpdate(state.updateAvailable);
586
- }, [startUpdate, state.updateAvailable, state.updateCheckInProgress, state.updateProgress]);
698
+ }, [isDetachedWindow, startUpdate, state.updateAvailable, state.updateCheckInProgress, state.updateProgress]);
587
699
 
588
700
  // Load tickers on mount
589
701
  useEffect(() => {
@@ -593,12 +705,13 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
593
705
  try {
594
706
  let persistedBrokerAccounts: Record<string, BrokerAccount[]> = {};
595
707
  try {
596
- persistedBrokerAccounts = loadPersistedIbkrAccountMap(
708
+ persistedBrokerAccounts = loadPersistedBrokerAccountMap(
597
709
  pluginRegistry.persistence.resources,
598
710
  state.config.brokerInstances,
711
+ pluginRegistry.brokers,
599
712
  );
600
713
  } catch {}
601
- await initializeAppState({
714
+ await measurePerfAsync("startup.app.initialize-state", () => initializeAppState({
602
715
  config: state.config,
603
716
  tickerRepository,
604
717
  dataProvider,
@@ -609,6 +722,10 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
609
722
  refreshQuote,
610
723
  autoImportBrokerPositions,
611
724
  persistedBrokerAccounts,
725
+ }), {
726
+ brokerInstanceCount: state.config.brokerInstances.length,
727
+ layoutPaneCount: state.config.layout.instances.length,
728
+ sessionHydrationTargetCount: sessionSnapshot?.hydrationTargets.length ?? 0,
612
729
  });
613
730
  } catch (err) {
614
731
  // Will show empty state
@@ -622,6 +739,10 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
622
739
  if (!focusedTickerSymbol) return;
623
740
  const ticker = state.tickers.get(focusedTickerSymbol);
624
741
  if (!ticker) return;
742
+ appLog.info("focused ticker prefetch scheduled", {
743
+ symbol: ticker.metadata.ticker,
744
+ exchange: ticker.metadata.exchange,
745
+ });
625
746
  marketData.prefetchTicker(instrumentFromTicker(ticker, ticker.metadata.ticker));
626
747
  }, [focusedTickerSymbol, marketData, state.tickers]);
627
748
 
@@ -640,21 +761,25 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
640
761
  pluginRegistry.getPluginConfigValueFn = (pluginId, key) => (
641
762
  (state.config.pluginConfig[pluginId]?.[key] as any) ?? null
642
763
  );
643
- pluginRegistry.setPluginConfigValueFn = async (pluginId, key, value) => {
764
+ const setPluginConfigValues = async (pluginId: string, values: Record<string, unknown>) => {
644
765
  const nextConfig = {
645
766
  ...state.config,
646
767
  pluginConfig: {
647
768
  ...state.config.pluginConfig,
648
769
  [pluginId]: {
649
770
  ...(state.config.pluginConfig[pluginId] ?? {}),
650
- [key]: value,
771
+ ...values,
651
772
  },
652
773
  },
653
774
  };
654
775
  dispatch({ type: "SET_CONFIG", config: nextConfig });
655
- await saveConfig(nextConfig);
776
+ await saveConfigImmediately(nextConfig);
656
777
  pluginRegistry.events.emit("config:changed", { config: nextConfig });
657
778
  };
779
+ pluginRegistry.setPluginConfigValueFn = async (pluginId, key, value) => {
780
+ await setPluginConfigValues(pluginId, { [key]: value });
781
+ };
782
+ pluginRegistry.setPluginConfigValuesFn = setPluginConfigValues;
658
783
  pluginRegistry.deletePluginConfigValueFn = async (pluginId, key) => {
659
784
  const currentPluginConfig = state.config.pluginConfig[pluginId];
660
785
  if (!currentPluginConfig || !(key in currentPluginConfig)) return;
@@ -674,11 +799,14 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
674
799
  pluginConfig: nextAllPluginConfig,
675
800
  };
676
801
  dispatch({ type: "SET_CONFIG", config: nextConfig });
677
- await saveConfig(nextConfig);
802
+ await saveConfigImmediately(nextConfig);
678
803
  pluginRegistry.events.emit("config:changed", { config: nextConfig });
679
804
  };
680
- if (dataProvider instanceof ProviderRouter) {
681
- dataProvider.setConfigAccessor(() => state.config);
805
+ const configurableProvider = dataProvider as DataProvider & {
806
+ setConfigAccessor?: (accessor: () => AppConfig) => void;
807
+ };
808
+ if (typeof configurableProvider.setConfigAccessor === "function") {
809
+ configurableProvider.setConfigAccessor(() => state.config);
682
810
  }
683
811
  pluginRegistry.createBrokerInstanceFn = async (brokerType, label, values) => {
684
812
  const instanceId = createBrokerInstanceId(
@@ -699,34 +827,58 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
699
827
  brokerInstances: [...state.config.brokerInstances, instance],
700
828
  };
701
829
  dispatch({ type: "SET_CONFIG", config: nextConfig });
702
- await saveConfig(nextConfig);
830
+ await saveConfigImmediately(nextConfig);
703
831
  pluginRegistry.events.emit("config:changed", { config: nextConfig });
704
832
  return instance;
705
833
  };
706
- pluginRegistry.updateBrokerInstanceFn = async (instanceId, values) => {
834
+ pluginRegistry.connectBrokerInstanceFn = async (instanceId) => {
835
+ const instance = getBrokerInstance(state.config.brokerInstances, instanceId);
836
+ if (!instance) throw new Error("Broker profile not found.");
837
+ if (instance.enabled === false) throw new Error(`Broker profile "${instance.label}" is disabled.`);
838
+
839
+ const broker = pluginRegistry.brokers.get(instance.brokerType);
840
+ if (!broker) throw new Error(`Broker "${instance.brokerType}" is not available.`);
841
+
842
+ const valid = await broker.validate(instance).catch(() => false);
843
+ if (!valid) throw new Error(`${broker.name} setup is incomplete.`);
844
+
845
+ await broker.connect?.(instance);
846
+ if (broker.listAccounts) {
847
+ const accounts = await broker.listAccounts(instance);
848
+ dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts });
849
+ }
850
+ };
851
+ pluginRegistry.updateBrokerInstanceFn = async (instanceId, values, options = {}) => {
707
852
  const currentInstance = state.config.brokerInstances.find((instance) => instance.id === instanceId);
708
853
  const nextInstances = state.config.brokerInstances.map((instance) =>
709
854
  instance.id === instanceId
710
- ? {
711
- ...instance,
712
- connectionMode: typeof values.connectionMode === "string" ? values.connectionMode : instance.connectionMode,
713
- config: { ...instance.config, ...values },
714
- }
855
+ ? (() => {
856
+ const nextValues = options.replaceConfig ? values : { ...instance.config, ...values };
857
+ return {
858
+ ...instance,
859
+ label: options.label ?? instance.label,
860
+ enabled: options.enabled ?? instance.enabled,
861
+ connectionMode: typeof nextValues.connectionMode === "string" ? nextValues.connectionMode : instance.connectionMode,
862
+ config: nextValues,
863
+ };
864
+ })()
715
865
  : instance,
716
866
  );
717
867
  const nextInstance = nextInstances.find((instance) => instance.id === instanceId);
718
- const shouldClearIbkrAccounts = currentInstance?.brokerType === "ibkr"
719
- && nextInstance?.brokerType === "ibkr"
720
- && getIbkrConfigIdentity(currentInstance.config) !== getIbkrConfigIdentity(nextInstance.config);
721
- if (shouldClearIbkrAccounts) {
722
- clearPersistedIbkrAccounts(pluginRegistry.persistence.resources, instanceId);
868
+ const broker = currentInstance ? pluginRegistry.brokers.get(currentInstance.brokerType) : null;
869
+ const shouldClearBrokerAccounts = currentInstance
870
+ && nextInstance
871
+ && currentInstance.brokerType === nextInstance.brokerType
872
+ && getBrokerAccountCacheSourceKey(currentInstance, broker) !== getBrokerAccountCacheSourceKey(nextInstance, broker);
873
+ if (shouldClearBrokerAccounts) {
874
+ clearPersistedBrokerAccounts(pluginRegistry.persistence.resources, currentInstance);
723
875
  }
724
876
  const nextConfig = {
725
877
  ...state.config,
726
878
  brokerInstances: nextInstances,
727
879
  };
728
880
  dispatch({ type: "SET_CONFIG", config: nextConfig });
729
- await saveConfig(nextConfig);
881
+ await saveConfigImmediately(nextConfig);
730
882
  pluginRegistry.events.emit("config:changed", { config: nextConfig });
731
883
  };
732
884
  pluginRegistry.syncBrokerInstanceFn = async (instanceId) => {
@@ -736,7 +888,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
736
888
  const instance = getBrokerInstance(state.config.brokerInstances, instanceId);
737
889
  if (!instance) return;
738
890
 
739
- clearPersistedIbkrAccounts(pluginRegistry.persistence.resources, instanceId);
891
+ clearPersistedBrokerAccounts(pluginRegistry.persistence.resources, instance);
740
892
 
741
893
  const broker = pluginRegistry.brokers.get(instance.brokerType);
742
894
  await broker?.disconnect?.(instance).catch(() => {});
@@ -793,16 +945,23 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
793
945
 
794
946
  dispatch({ type: "SET_CONFIG", config: nextConfig });
795
947
  dispatch({ type: "SET_TICKERS", tickers: nextTickers });
796
- await saveConfig(nextConfig);
948
+ await saveConfigImmediately(nextConfig);
797
949
  pluginRegistry.events.emit("config:changed", { config: nextConfig });
798
950
  };
799
951
 
800
952
  // Wire up navigation functions
801
953
  pluginRegistry.selectTickerFn = (symbol, paneId) => selectTickerInPane(symbol, paneId);
802
- pluginRegistry.switchPanelFn = (panel) => dispatch({ type: "SET_ACTIVE_PANEL", panel });
954
+ pluginRegistry.switchPanelFn = (panel) => {
955
+ if (isDetachedWindow) return;
956
+ dispatch({ type: "SET_ACTIVE_PANEL", panel });
957
+ };
803
958
  pluginRegistry.switchTabFn = (tabId, paneId) => switchDetailTab(tabId, paneId);
804
- pluginRegistry.openCommandBarFn = (query) => dispatch({ type: "SET_COMMAND_BAR", open: true, query });
959
+ pluginRegistry.openCommandBarFn = (query) => {
960
+ if (isDetachedWindow) return;
961
+ dispatch({ type: "SET_COMMAND_BAR", open: true, query });
962
+ };
805
963
  pluginRegistry.openPluginCommandWorkflowFn = (commandId) => {
964
+ if (isDetachedWindow) return;
806
965
  dispatch({
807
966
  type: "SET_COMMAND_BAR",
808
967
  open: true,
@@ -820,7 +979,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
820
979
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
821
980
  }
822
981
  dispatch({ type: "UPDATE_LAYOUT", layout: normalizedLayout });
823
- saveConfig({ ...currentState.config, layout: normalizedLayout, layouts }).catch(() => {});
982
+ scheduleConfigSave({ ...currentState.config, layout: normalizedLayout, layouts });
824
983
  };
825
984
  const resolvePanelForPane = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): "left" | "right" => {
826
985
  const instanceId = resolvePaneTarget(paneId, layout);
@@ -1022,10 +1181,17 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1022
1181
  }, [dataProvider, dialog, dispatch, persistLayout, pluginRegistry, resolvePaneTarget, tickerRepository]);
1023
1182
 
1024
1183
  pluginRegistry.getLayoutFn = () => state.config.layout;
1025
- pluginRegistry.updateLayoutFn = (layout) => persistLayout(layout);
1184
+ pluginRegistry.updateLayoutFn = (layout) => {
1185
+ if (isDetachedWindow) return;
1186
+ persistLayout(layout);
1187
+ };
1026
1188
  pluginRegistry.openPaneSettingsFn = (paneId) => { void openPaneSettings(paneId); };
1027
- pluginRegistry.showPaneFn = (paneId) => showPane(paneId);
1189
+ pluginRegistry.showPaneFn = (paneId) => {
1190
+ if (isDetachedWindow) return;
1191
+ showPane(paneId);
1192
+ };
1028
1193
  pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
1194
+ if (isDetachedWindow) return;
1029
1195
  await createPaneTemplateOrThrow(templateId, options, {
1030
1196
  dataProvider,
1031
1197
  tickerRepository,
@@ -1036,7 +1202,10 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1036
1202
  placePaneInstance,
1037
1203
  });
1038
1204
  };
1039
- pluginRegistry.createPaneFromTemplateFn = (templateId, options) => { void createPaneFromTemplate(templateId, options); };
1205
+ pluginRegistry.createPaneFromTemplateFn = (templateId, options) => {
1206
+ if (isDetachedWindow) return;
1207
+ void createPaneFromTemplate(templateId, options);
1208
+ };
1040
1209
  pluginRegistry.applyPaneSettingValueFn = async (paneId, field, value) => {
1041
1210
  await applyPaneSettingFieldValueShared(paneId, field, value, {
1042
1211
  dataProvider,
@@ -1048,11 +1217,18 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1048
1217
  });
1049
1218
  };
1050
1219
  pluginRegistry.hidePaneFn = (paneId) => {
1220
+ if (isDetachedWindow) return;
1051
1221
  const instanceId = resolvePaneTarget(paneId);
1052
1222
  if (!instanceId || !isPaneInLayout(state.config.layout, instanceId)) return;
1053
1223
  persistLayout(removePane(state.config.layout, instanceId));
1054
1224
  };
1055
1225
  pluginRegistry.focusPaneFn = (paneId) => {
1226
+ if (isDetachedWindow) {
1227
+ if (paneId === detachedPaneId) {
1228
+ dispatch({ type: "FOCUS_PANE", paneId });
1229
+ }
1230
+ return;
1231
+ }
1056
1232
  const instanceId = resolvePaneTarget(paneId);
1057
1233
  if (!instanceId || !isPaneInLayout(state.config.layout, instanceId)) {
1058
1234
  showPane(paneId);
@@ -1069,6 +1245,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1069
1245
  activatePane(instanceId, layout);
1070
1246
  };
1071
1247
  pluginRegistry.pinTickerFn = (symbol, options) => {
1248
+ if (isDetachedWindow) return;
1072
1249
  const paneType = options?.paneType ?? "ticker-detail";
1073
1250
  const paneDef = pluginRegistry.panes.get(paneType);
1074
1251
  if (!paneDef) return;
@@ -1096,6 +1273,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1096
1273
  };
1097
1274
 
1098
1275
  pluginRegistry.navigateTickerFn = (rawSymbol) => {
1276
+ if (isDetachedWindow) return;
1099
1277
  (async () => {
1100
1278
  try {
1101
1279
  // Resolve or create the ticker in the local database
@@ -1170,7 +1348,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1170
1348
  activatePane(detailPane.instanceId, nextLayout);
1171
1349
  }
1172
1350
  } else {
1173
- pluginRegistry.pinTickerFn(symbol, { floating: false });
1351
+ pluginRegistry.pinTicker(symbol, { floating: false });
1174
1352
  }
1175
1353
  } catch (err) {
1176
1354
  const message = err instanceof Error ? err.message : String(err);
@@ -1194,7 +1372,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1194
1372
  useEffect(() => {
1195
1373
  if (state.config.layouts !== prevLayouts.current) {
1196
1374
  prevLayouts.current = state.config.layouts;
1197
- saveConfig(state.config).catch(() => {});
1375
+ scheduleConfigSave(state.config);
1198
1376
  }
1199
1377
  }, [state.config.layouts, state.config]);
1200
1378
 
@@ -1216,13 +1394,13 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1216
1394
  const focusedDetailTab = state.focusedPaneId ? state.paneState[state.focusedPaneId]?.activeTabId : undefined;
1217
1395
 
1218
1396
  // Global keyboard shortcuts
1219
- useKeyboard((event) => {
1220
- if (isCopyShortcut(event) && copyActiveSelection(renderer)) {
1397
+ useShortcut((event) => {
1398
+ if (isCopyShortcut(event) && copyActiveSelection(nativeRenderer)) {
1221
1399
  event.preventDefault();
1222
1400
  event.stopPropagation();
1223
1401
  return;
1224
1402
  }
1225
- if (isPasteShortcut(event) && pasteSystemClipboard(renderer)) {
1403
+ if (isPasteShortcut(event) && pasteSystemClipboard(nativeRenderer)) {
1226
1404
  event.preventDefault();
1227
1405
  event.stopPropagation();
1228
1406
  return;
@@ -1231,18 +1409,25 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1231
1409
  // Skip all global shortcuts when a dialog is open
1232
1410
  if (dialogOpen) return;
1233
1411
 
1234
- // Ctrl+P: toggle command bar (backtick handled in command-bar itself for close)
1235
- if (event.name === "p" && event.ctrl) {
1412
+ // Ctrl+P / Cmd+K: toggle command bar (backtick close is handled in command-bar.tsx)
1413
+ if (!isDetachedWindow && (
1414
+ (event.name === "p" && event.ctrl)
1415
+ || (event.name === "k" && (event.meta || event.super))
1416
+ )) {
1417
+ event.preventDefault();
1418
+ event.stopPropagation();
1236
1419
  dispatch({ type: "TOGGLE_COMMAND_BAR" });
1237
1420
  return;
1238
1421
  }
1239
1422
  // Backtick opens command bar (close is handled in command-bar.tsx)
1240
- if (event.name === "`" && !state.commandBarOpen) {
1423
+ if (!isDetachedWindow && event.name === "`" && !state.commandBarOpen) {
1424
+ event.preventDefault();
1425
+ event.stopPropagation();
1241
1426
  dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
1242
1427
  return;
1243
1428
  }
1244
1429
  // Ctrl+1-9: switch layouts (works even when input is captured)
1245
- if (/^[1-9]$/.test(event.name ?? "") && event.ctrl && (state.config.layouts ?? []).length > 1) {
1430
+ if (!isDetachedWindow && /^[1-9]$/.test(event.name ?? "") && event.ctrl && (state.config.layouts ?? []).length > 1) {
1246
1431
  const idx = parseInt(event.name!, 10) - 1;
1247
1432
  const layouts = state.config.layouts ?? [];
1248
1433
  if (idx < layouts.length && idx !== state.config.activeLayoutIndex) {
@@ -1268,8 +1453,8 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1268
1453
  } else {
1269
1454
  dispatch({ type: "FOCUS_NEXT", paneOrder });
1270
1455
  }
1271
- } else if (event.name === "q" && !(focusedPane?.paneId === "ticker-detail" && focusedDetailTab === "financials")) {
1272
- renderer.destroy();
1456
+ } else if (!isDetachedWindow && event.name === "q" && !(focusedPane?.paneId === "ticker-detail" && focusedDetailTab === "financials")) {
1457
+ rendererHost.requestExit();
1273
1458
  } else if (event.name === "r") {
1274
1459
  // Refresh focused ticker context.
1275
1460
  if (focusedTickerSymbol) {
@@ -1281,7 +1466,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1281
1466
  for (const t of state.tickers.values()) {
1282
1467
  refreshTicker(t.metadata.ticker, t.metadata.exchange, t, 1);
1283
1468
  }
1284
- } else if (event.name === "a" && focusedTickerSymbol) {
1469
+ } else if (!isDetachedWindow && event.name === "a" && focusedTickerSymbol) {
1285
1470
  // Open ticker actions
1286
1471
  const actions = [...pluginRegistry.tickerActions.values()];
1287
1472
  const ticker = state.tickers.get(focusedTickerSymbol);
@@ -1306,53 +1491,90 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1306
1491
  }
1307
1492
  });
1308
1493
 
1494
+ if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
1495
+ return (
1496
+ <ContextMenuProvider pluginRegistry={pluginRegistry}>
1497
+ <Box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1498
+ <DetachedPaneShell
1499
+ pluginRegistry={pluginRegistry}
1500
+ desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
1501
+ />
1502
+ <ToastViewport position="bottom-right" />
1503
+ </Box>
1504
+ </ContextMenuProvider>
1505
+ );
1506
+ }
1507
+
1309
1508
  return (
1310
- <box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1311
- <Header />
1312
- <Shell pluginRegistry={pluginRegistry} />
1313
- <StatusBar />
1314
- {state.commandBarOpen && (
1315
- <CommandBar
1316
- dataProvider={dataProvider}
1317
- tickerRepository={tickerRepository}
1509
+ <ContextMenuProvider pluginRegistry={pluginRegistry}>
1510
+ <Box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1511
+ <Header />
1512
+ <Shell
1318
1513
  pluginRegistry={pluginRegistry}
1319
- quitApp={() => renderer.destroy()}
1320
- onCheckForUpdates={() => runUpdateCheck(true)}
1514
+ desktopWindowBridge={desktopWindowBridge}
1515
+ desktopDockPreview={desktopDockPreview}
1321
1516
  />
1322
- )}
1323
- <Toaster position="bottom-right" />
1324
- </box>
1517
+ <StatusBar />
1518
+ {state.commandBarOpen && (
1519
+ <CommandBar
1520
+ dataProvider={dataProvider}
1521
+ tickerRepository={tickerRepository}
1522
+ pluginRegistry={pluginRegistry}
1523
+ quitApp={() => rendererHost.requestExit()}
1524
+ onCheckForUpdates={() => runUpdateCheck(true)}
1525
+ />
1526
+ )}
1527
+ <ToastViewport position="bottom-right" />
1528
+ </Box>
1529
+ </ContextMenuProvider>
1325
1530
  );
1326
1531
  }
1327
1532
 
1328
1533
  interface AppProps {
1329
1534
  config: AppConfig;
1330
- renderer: CliRenderer;
1331
1535
  externalPlugins?: import("./plugins/loader").LoadedExternalPlugin[];
1332
1536
  cliLaunchRequest?: CliLaunchRequest | null;
1537
+ desktopWindowBridge?: DesktopWindowBridge;
1538
+ desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
1539
+ desktopSnapshot?: DesktopSharedStateSnapshot | null;
1333
1540
  }
1334
1541
 
1335
1542
  export function App({
1336
1543
  config: initialConfig,
1337
- renderer,
1338
1544
  externalPlugins = [],
1339
1545
  cliLaunchRequest = null,
1546
+ desktopWindowBridge,
1547
+ desktopApplicationMenuBridge,
1548
+ desktopSnapshot = null,
1340
1549
  }: AppProps) {
1550
+ const renderer = useNativeRenderer();
1551
+ const effectiveInitialConfig = useMemo(() => {
1552
+ const baseConfig = desktopSnapshot?.config ?? initialConfig;
1553
+ if (desktopWindowBridge) return baseConfig;
1554
+ return {
1555
+ ...baseConfig,
1556
+ layout: materializeDetachedPanesAsFloating(baseConfig.layout),
1557
+ layouts: baseConfig.layouts.map((entry) => ({
1558
+ ...entry,
1559
+ layout: materializeDetachedPanesAsFloating(entry.layout),
1560
+ })),
1561
+ };
1562
+ }, [desktopSnapshot?.config, desktopWindowBridge, initialConfig]);
1341
1563
  const initialCliLaunch = useMemo(() => {
1342
1564
  if (!cliLaunchRequest) {
1343
- return { config: initialConfig, launchState: undefined };
1565
+ return { config: effectiveInitialConfig, launchState: undefined };
1344
1566
  }
1345
- return cliLaunchRequest.applyConfig(initialConfig, {
1567
+ return cliLaunchRequest.applyConfig(effectiveInitialConfig, {
1346
1568
  terminalWidth: renderer.terminalWidth,
1347
1569
  terminalHeight: renderer.terminalHeight,
1348
1570
  });
1349
- }, [cliLaunchRequest, initialConfig, renderer.terminalHeight, renderer.terminalWidth]);
1571
+ }, [cliLaunchRequest, effectiveInitialConfig, renderer.terminalHeight, renderer.terminalWidth]);
1350
1572
  const cliLaunchStateRef = useRef(initialCliLaunch.launchState);
1351
1573
 
1352
1574
  const [config, setConfig] = useState(() => {
1353
1575
  return initialCliLaunch.config;
1354
1576
  });
1355
- const [showOnboarding, setShowOnboarding] = useState(!initialConfig.onboardingComplete);
1577
+ const [showOnboarding, setShowOnboarding] = useState(!effectiveInitialConfig.onboardingComplete);
1356
1578
 
1357
1579
  // Keep the shared palette aligned before this render builds any JSX that reads `colors`.
1358
1580
  syncTheme(config.theme);
@@ -1360,44 +1582,44 @@ export function App({
1360
1582
  useEffect(() => bindAppActivity(renderer), [renderer]);
1361
1583
 
1362
1584
  const services = useMemo(() => {
1363
- const dbPath = join(config.dataDir, ".gloomberb-cache.db");
1364
- const persistence = new AppPersistence(dbPath);
1365
- const tickerRepository = new TickerRepository(persistence.tickers);
1366
- const providerRouter = new ProviderRouter(null, [], persistence.resources);
1367
- const dataProvider: DataProvider = providerRouter;
1368
- const marketData = new MarketDataCoordinator(dataProvider);
1369
- const pluginRegistry = new PluginRegistry(renderer, dataProvider, tickerRepository, persistence);
1370
- providerRouter.attachRegistry(pluginRegistry);
1371
- pluginRegistry.getConfigFn = () => config;
1372
- pluginRegistry.getLayoutFn = () => config.layout;
1373
-
1374
- for (const plugin of getLoadablePlugins(externalPlugins)) {
1375
- pluginRegistry.register(plugin);
1376
- }
1377
-
1378
- return {
1379
- persistence,
1380
- tickerRepository,
1381
- providerRouter,
1382
- dataProvider,
1383
- marketData,
1384
- pluginRegistry,
1385
- };
1386
- }, [config.dataDir, externalPlugins, renderer]);
1387
-
1388
- if (services.marketData !== null) {
1389
- setSharedMarketDataCoordinator(services.marketData);
1390
- }
1585
+ return measurePerf("startup.app.create-services", () => (
1586
+ createAppServices({ config, externalPlugins })
1587
+ ), {
1588
+ externalPluginCount: externalPlugins.length,
1589
+ disabledPluginCount: config.disabledPlugins.length,
1590
+ brokerInstanceCount: config.brokerInstances.length,
1591
+ });
1592
+ }, [config.dataDir, externalPlugins]);
1391
1593
 
1392
1594
  useEffect(() => {
1393
- return () => {
1394
- setSharedMarketDataCoordinator(null);
1395
- services.pluginRegistry.destroy();
1396
- services.persistence.close();
1397
- };
1595
+ return () => services.destroy();
1398
1596
  }, [services]);
1399
1597
 
1400
1598
  const sessionSnapshot = useMemo(() => {
1599
+ if (desktopWindowBridge?.kind === "detached") {
1600
+ const baseSessionSnapshot = reconcileAppSessionSnapshot(
1601
+ config,
1602
+ services.persistence.sessions.get<AppSessionSnapshot>(APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION)?.value ?? null,
1603
+ ) ?? {
1604
+ paneState: {},
1605
+ focusedPaneId: null,
1606
+ activePanel: "left" as const,
1607
+ statusBarVisible: true,
1608
+ openPaneIds: [],
1609
+ hydrationTargets: [],
1610
+ exchangeCurrencies: [],
1611
+ savedAt: Date.now(),
1612
+ };
1613
+ return desktopSnapshot
1614
+ ? {
1615
+ ...baseSessionSnapshot,
1616
+ paneState: desktopSnapshot.paneState,
1617
+ focusedPaneId: desktopSnapshot.focusedPaneId,
1618
+ activePanel: desktopSnapshot.activePanel,
1619
+ statusBarVisible: desktopSnapshot.statusBarVisible,
1620
+ }
1621
+ : null;
1622
+ }
1401
1623
  const persisted = services.persistence.sessions.get<AppSessionSnapshot>(APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION)?.value ?? null;
1402
1624
  const reconciled = reconcileAppSessionSnapshot(config, persisted);
1403
1625
  if (!cliLaunchRequest?.applySessionSnapshot) {
@@ -1408,7 +1630,7 @@ export function App({
1408
1630
  reconciled,
1409
1631
  cliLaunchStateRef.current,
1410
1632
  );
1411
- }, [cliLaunchRequest, config, services.persistence.sessions]);
1633
+ }, [cliLaunchRequest, config, desktopSnapshot, desktopWindowBridge?.kind, services.persistence.sessions]);
1412
1634
 
1413
1635
  if (showOnboarding) {
1414
1636
  return (
@@ -1424,21 +1646,22 @@ export function App({
1424
1646
  }
1425
1647
 
1426
1648
  return (
1427
- <AppProvider config={config} sessionStore={services.persistence.sessions} sessionSnapshot={sessionSnapshot}>
1428
- <DialogProvider
1429
- size="medium"
1430
- dialogOptions={{ style: { backgroundColor: colors.bg, borderColor: colors.borderFocused, borderStyle: "single", paddingX: 2, paddingY: 1 } }}
1431
- backdropColor="#000000"
1432
- backdropOpacity={0.8}
1433
- >
1434
- <AppInner
1435
- pluginRegistry={services.pluginRegistry}
1436
- tickerRepository={services.tickerRepository}
1437
- dataProvider={services.dataProvider}
1438
- marketData={services.marketData}
1439
- sessionSnapshot={sessionSnapshot}
1440
- />
1441
- </DialogProvider>
1649
+ <AppProvider
1650
+ config={config}
1651
+ sessionStore={desktopWindowBridge?.kind === "detached" ? undefined : services.persistence.sessions}
1652
+ sessionSnapshot={sessionSnapshot}
1653
+ desktopBridge={desktopWindowBridge}
1654
+ desktopSnapshot={desktopSnapshot}
1655
+ >
1656
+ <AppInner
1657
+ pluginRegistry={services.pluginRegistry}
1658
+ tickerRepository={services.tickerRepository}
1659
+ dataProvider={services.dataProvider}
1660
+ marketData={services.marketData}
1661
+ sessionSnapshot={sessionSnapshot}
1662
+ desktopWindowBridge={desktopWindowBridge}
1663
+ desktopApplicationMenuBridge={desktopApplicationMenuBridge}
1664
+ />
1442
1665
  </AppProvider>
1443
1666
  );
1444
1667
  }