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
@@ -0,0 +1,896 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, Textarea, useRendererHost, type TextareaRenderable } from "../../ui";
3
+ import { useShortcut } from "../../react/input";
4
+ import {
5
+ DataTableStackView,
6
+ EmptyState,
7
+ SegmentedControl,
8
+ Tabs,
9
+ TickerBadgeList,
10
+ usePaneFooter,
11
+ type DataTableCell,
12
+ type DataTableColumn,
13
+ type DataTableKeyEvent,
14
+ } from "../../components";
15
+ import type { DetailTabProps, PaneProps } from "../../types/plugin";
16
+ import { usePaneInstance, usePaneInstanceId, usePaneTicker } from "../../state/app-context";
17
+ import { usePluginPaneState, usePluginState } from "../plugin-runtime";
18
+ import { TickerBadgeText } from "../../components/ticker-badge-text";
19
+ import { ExternalLinkText, RemoteImage } from "../../components/ui";
20
+ import { useInlineTickers } from "../../state/use-inline-tickers";
21
+ import { apiClient, type CloudTweetPayload, type CloudTweetQueryType, type CloudTweetSearchResponse } from "../../utils/api-client";
22
+ import { collectUniqueTickerSymbols } from "../../utils/ticker-tokenizer";
23
+ import { formatCompact, formatTimeAgo } from "../../utils/format";
24
+ import { normalizeTweetText } from "../../utils/tweet-text";
25
+ import { toTimestampMillis } from "../../utils/timestamp";
26
+ import { normalizedHttpUrl } from "../../utils/url";
27
+ import { colors } from "../../theme/colors";
28
+ import { CloudAuthNotice } from "./cloud-auth-actions";
29
+
30
+ const DEFAULT_TWEET_HOURS = 6;
31
+ const DEFAULT_TWEET_LIMIT = 50;
32
+ const TWEET_SEARCH_SCHEMA_VERSION = 1;
33
+
34
+ type TweetColumnId = "time" | "author" | "text" | "tickers" | "likes" | "views";
35
+ type TweetColumn = DataTableColumn & { id: TweetColumnId };
36
+ type TweetSortColumnId = "time" | "likes" | "views";
37
+ type TweetSortDirection = "asc" | "desc";
38
+
39
+ interface TweetLoadState {
40
+ data: CloudTweetSearchResponse | null;
41
+ loading: boolean;
42
+ error: string | null;
43
+ }
44
+
45
+ interface TwitterFeed {
46
+ id: string;
47
+ title: string;
48
+ query: string;
49
+ queryType: CloudTweetQueryType;
50
+ createdAt: number;
51
+ updatedAt: number;
52
+ lastSuccessAt: number | null;
53
+ lastError: string | null;
54
+ }
55
+
56
+ interface PersistedTwitterFeedState {
57
+ feeds: TwitterFeed[];
58
+ }
59
+
60
+ interface FeedEditorState {
61
+ mode: "create" | "edit";
62
+ feedId: string | null;
63
+ query: string;
64
+ queryType: CloudTweetQueryType;
65
+ key: string;
66
+ error: string | null;
67
+ }
68
+
69
+ const EMPTY_FEED_STATE: PersistedTwitterFeedState = { feeds: [] };
70
+ let nextTwitterFeedId = 1;
71
+ let nextTwitterEditorId = 1;
72
+
73
+ function generateFeedId(): string {
74
+ return `${Date.now()}-${nextTwitterFeedId++}`;
75
+ }
76
+
77
+ function generateEditorKey(): string {
78
+ return `twitter-editor-${Date.now()}-${nextTwitterEditorId++}`;
79
+ }
80
+
81
+ function truncateWithEllipsis(text: string, width: number): string {
82
+ if (width <= 0) return "";
83
+ if (text.length <= width) return text;
84
+ if (width <= 3) return text.slice(0, width);
85
+ return `${text.slice(0, width - 3)}...`;
86
+ }
87
+
88
+ function deriveFeedTitle(query: string): string {
89
+ const tickers = collectUniqueTickerSymbols([query]);
90
+ if (tickers.length > 0) return tickers.slice(0, 3).map((ticker) => `$${ticker}`).join(" ");
91
+ return truncateWithEllipsis(query.replace(/\s+/g, " ").trim(), 24) || "X Feed";
92
+ }
93
+
94
+ function createFeed(query: string, queryType: CloudTweetQueryType): TwitterFeed {
95
+ const now = Date.now();
96
+ return {
97
+ id: generateFeedId(),
98
+ title: deriveFeedTitle(query),
99
+ query,
100
+ queryType,
101
+ createdAt: now,
102
+ updatedAt: now,
103
+ lastSuccessAt: null,
104
+ lastError: null,
105
+ };
106
+ }
107
+
108
+ function normalizeFeeds(value: unknown): TwitterFeed[] {
109
+ const entries = Array.isArray((value as PersistedTwitterFeedState | undefined)?.feeds)
110
+ ? (value as PersistedTwitterFeedState).feeds
111
+ : [];
112
+ return entries
113
+ .filter((entry): entry is TwitterFeed => (
114
+ !!entry
115
+ && typeof entry === "object"
116
+ && typeof entry.id === "string"
117
+ && typeof entry.query === "string"
118
+ ))
119
+ .map((entry) => ({
120
+ ...entry,
121
+ title: typeof entry.title === "string" && entry.title.trim() ? entry.title : deriveFeedTitle(entry.query),
122
+ queryType: entry.queryType === "Top" ? "Top" : "Latest",
123
+ createdAt: typeof entry.createdAt === "number" ? entry.createdAt : Date.now(),
124
+ updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : Date.now(),
125
+ lastSuccessAt: typeof entry.lastSuccessAt === "number" ? entry.lastSuccessAt : null,
126
+ lastError: typeof entry.lastError === "string" ? entry.lastError : null,
127
+ }));
128
+ }
129
+
130
+ function formatRelativeShort(value: string): string {
131
+ return formatTimeAgo(value).replace(" ago", "").replace("just now", "<1m");
132
+ }
133
+
134
+ function formatMetric(value: number | null | undefined): string {
135
+ if (value == null) return "-";
136
+ if (Math.abs(value) >= 1000) return formatCompact(value);
137
+ return String(value);
138
+ }
139
+
140
+ function normalizeTweetDisplayText(value: string): string {
141
+ return normalizeTweetText(value, { preserveLineBreaks: true });
142
+ }
143
+
144
+ function normalizeTweetCellText(value: string): string {
145
+ return normalizeTweetText(value);
146
+ }
147
+
148
+ function tweetTickers(tweet: CloudTweetPayload): string[] {
149
+ return collectUniqueTickerSymbols([normalizeTweetDisplayText(tweet.text)]);
150
+ }
151
+
152
+ function tweetCreatedAtMs(tweet: CloudTweetPayload): number {
153
+ const ms = toTimestampMillis(tweet.createdAt);
154
+ return Number.isFinite(ms) ? ms : 0;
155
+ }
156
+
157
+ function compareNullableNumber(
158
+ left: number | null | undefined,
159
+ right: number | null | undefined,
160
+ sortDirection: TweetSortDirection,
161
+ ): number {
162
+ if (left == null && right == null) return 0;
163
+ if (left == null) return 1;
164
+ if (right == null) return -1;
165
+ const comparison = left - right;
166
+ return sortDirection === "asc" ? comparison : -comparison;
167
+ }
168
+
169
+ function isTweetSortColumnId(columnId: string): columnId is TweetSortColumnId {
170
+ return columnId === "time" || columnId === "likes" || columnId === "views";
171
+ }
172
+
173
+ function compareTweets(
174
+ left: CloudTweetPayload,
175
+ right: CloudTweetPayload,
176
+ sortColumnId: TweetSortColumnId,
177
+ sortDirection: TweetSortDirection,
178
+ ): number {
179
+ let comparison = 0;
180
+ switch (sortColumnId) {
181
+ case "time":
182
+ comparison = tweetCreatedAtMs(left) - tweetCreatedAtMs(right);
183
+ if (comparison !== 0) {
184
+ return sortDirection === "asc" ? comparison : -comparison;
185
+ }
186
+ break;
187
+ case "likes":
188
+ comparison = compareNullableNumber(left.metrics.likes, right.metrics.likes, sortDirection);
189
+ break;
190
+ case "views":
191
+ comparison = compareNullableNumber(left.metrics.views, right.metrics.views, sortDirection);
192
+ break;
193
+ }
194
+
195
+ if (comparison !== 0) return comparison;
196
+
197
+ return tweetCreatedAtMs(right) - tweetCreatedAtMs(left);
198
+ }
199
+
200
+ function sortedTweets(
201
+ tweets: CloudTweetPayload[],
202
+ sortColumnId: TweetSortColumnId,
203
+ sortDirection: TweetSortDirection,
204
+ ): CloudTweetPayload[] {
205
+ return [...tweets].sort((left, right) => compareTweets(left, right, sortColumnId, sortDirection));
206
+ }
207
+
208
+ function mediaImageUrl(value: unknown): string | null {
209
+ const directUrl = normalizedHttpUrl(value);
210
+ if (directUrl) return directUrl;
211
+ if (!value || typeof value !== "object") return null;
212
+
213
+ const record = value as Record<string, unknown>;
214
+ return normalizedHttpUrl(record.mediaUrl)
215
+ ?? normalizedHttpUrl(record.media_url_https)
216
+ ?? normalizedHttpUrl(record.media_url)
217
+ ?? normalizedHttpUrl(record.previewImageUrl)
218
+ ?? normalizedHttpUrl(record.preview_image_url)
219
+ ?? normalizedHttpUrl(record.url);
220
+ }
221
+
222
+ function nestedMedia(record: Record<string, unknown>, key: string): unknown {
223
+ const value = record[key];
224
+ return value && typeof value === "object" ? (value as Record<string, unknown>).media : undefined;
225
+ }
226
+
227
+ function tweetImageUrls(tweet: CloudTweetPayload): string[] {
228
+ const record = tweet as unknown as Record<string, unknown>;
229
+ const candidates = [
230
+ record.media,
231
+ record.photos,
232
+ record.images,
233
+ nestedMedia(record, "entities"),
234
+ nestedMedia(record, "extendedEntities"),
235
+ nestedMedia(record, "extended_entities"),
236
+ ];
237
+ const urls = new Set<string>();
238
+ for (const candidate of candidates) {
239
+ const entries = Array.isArray(candidate) ? candidate : candidate == null ? [] : [candidate];
240
+ for (const entry of entries) {
241
+ const url = mediaImageUrl(entry);
242
+ if (url) urls.add(url);
243
+ }
244
+ }
245
+ return [...urls];
246
+ }
247
+
248
+ function buildTweetColumns(width: number): TweetColumn[] {
249
+ const timeWidth = 7;
250
+ const authorWidth = 16;
251
+ const tickersWidth = 24;
252
+ const likesWidth = 7;
253
+ const viewsWidth = 8;
254
+ const textWidth = Math.max(
255
+ 20,
256
+ width - timeWidth - authorWidth - tickersWidth - likesWidth - viewsWidth - 9,
257
+ );
258
+ return [
259
+ { id: "time", label: "TIME", width: timeWidth, align: "left" },
260
+ { id: "author", label: "AUTHOR", width: authorWidth, align: "left" },
261
+ { id: "text", label: "TWEET", width: textWidth, align: "left" },
262
+ { id: "tickers", label: "TICKERS", width: tickersWidth, align: "left" },
263
+ { id: "likes", label: "LIKES", width: likesWidth, align: "right" },
264
+ { id: "views", label: "VIEWS", width: viewsWidth, align: "right" },
265
+ ];
266
+ }
267
+
268
+ function isAuthError(error: string | null): boolean {
269
+ return !!error && /unauthorized|verification/i.test(error);
270
+ }
271
+
272
+ function TweetDetail({
273
+ tweet,
274
+ width,
275
+ }: {
276
+ tweet: CloudTweetPayload;
277
+ width: number;
278
+ }) {
279
+ const lineWidth = Math.max(1, width - 2);
280
+ const tweetText = normalizeTweetDisplayText(tweet.text);
281
+ const imageUrls = tweetImageUrls(tweet);
282
+ const imageWidth = Math.min(lineWidth, 72);
283
+ const imageHeight = Math.max(6, Math.min(14, Math.floor(imageWidth * 0.35)));
284
+ const { catalog, openTicker } = useInlineTickers([tweetText]);
285
+ const author = tweet.author.userName ? `@${tweet.author.userName}` : tweet.author.name;
286
+
287
+ return (
288
+ <ScrollBox scrollY focusable={false} flexGrow={1} paddingX={1}>
289
+ <Box flexDirection="column" width={lineWidth} gap={1}>
290
+ <Box flexDirection="row" height={1}>
291
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{author}</Text>
292
+ <Text fg={colors.textDim}> - {formatTimeAgo(tweet.createdAt)}</Text>
293
+ </Box>
294
+ <TickerBadgeText
295
+ text={tweetText}
296
+ lineWidth={lineWidth}
297
+ catalog={catalog}
298
+ textColor={colors.text}
299
+ openTicker={openTicker}
300
+ />
301
+ {imageUrls.length > 0 ? (
302
+ <Box flexDirection="column" gap={1}>
303
+ {imageUrls.slice(0, 4).map((url, index) => (
304
+ <RemoteImage
305
+ key={url}
306
+ src={url}
307
+ alt={`Tweet image ${index + 1}`}
308
+ width={imageWidth}
309
+ height={imageHeight}
310
+ label={imageUrls.length > 1 ? `image ${index + 1}` : "image"}
311
+ />
312
+ ))}
313
+ </Box>
314
+ ) : null}
315
+ <Box flexDirection="row" height={1}>
316
+ <Text fg={colors.textDim}>
317
+ {`likes ${formatMetric(tweet.metrics.likes)} reposts ${formatMetric(tweet.metrics.retweets)} replies ${formatMetric(tweet.metrics.replies)} views ${formatMetric(tweet.metrics.views)}`}
318
+ </Text>
319
+ </Box>
320
+ {tweet.url ? (
321
+ <Box height={1}>
322
+ <ExternalLinkText url={tweet.url} label="Open on X" color={colors.textBright} />
323
+ </Box>
324
+ ) : null}
325
+ </Box>
326
+ </ScrollBox>
327
+ );
328
+ }
329
+
330
+ function useTweetSearchData(
331
+ requestKey: string,
332
+ load: () => Promise<CloudTweetSearchResponse>,
333
+ onResult?: (result: CloudTweetSearchResponse) => void,
334
+ onError?: (message: string) => void,
335
+ ) {
336
+ const [state, setState] = useState<TweetLoadState>({
337
+ data: null,
338
+ loading: false,
339
+ error: null,
340
+ });
341
+ const fetchGenRef = useRef(0);
342
+ const onResultRef = useRef(onResult);
343
+ const onErrorRef = useRef(onError);
344
+ onResultRef.current = onResult;
345
+ onErrorRef.current = onError;
346
+
347
+ const reload = useCallback(() => {
348
+ fetchGenRef.current += 1;
349
+ const gen = fetchGenRef.current;
350
+ setState((current) => ({ ...current, loading: true, error: null }));
351
+ load()
352
+ .then((data) => {
353
+ if (fetchGenRef.current !== gen) return;
354
+ setState({ data, loading: false, error: null });
355
+ onResultRef.current?.(data);
356
+ })
357
+ .catch((error) => {
358
+ if (fetchGenRef.current !== gen) return;
359
+ const message = error instanceof Error ? error.message : String(error);
360
+ setState({ data: null, loading: false, error: message });
361
+ onErrorRef.current?.(message);
362
+ });
363
+ }, [load]);
364
+
365
+ useEffect(() => {
366
+ reload();
367
+ }, [reload, requestKey]);
368
+
369
+ return { ...state, reload };
370
+ }
371
+
372
+ function TweetSearchTable({
373
+ focused,
374
+ width,
375
+ height,
376
+ requestKey,
377
+ footerId,
378
+ load,
379
+ onResult,
380
+ onError,
381
+ }: {
382
+ focused: boolean;
383
+ width: number;
384
+ height: number;
385
+ requestKey: string;
386
+ footerId: string;
387
+ load: () => Promise<CloudTweetSearchResponse>;
388
+ onResult?: (result: CloudTweetSearchResponse) => void;
389
+ onError?: (message: string) => void;
390
+ }) {
391
+ const rendererHost = useRendererHost();
392
+ const { data, loading, error, reload } = useTweetSearchData(requestKey, load, onResult, onError);
393
+ const [selectedTweetId, setSelectedTweetId] = useState<string | null>(null);
394
+ const [detailOpen, setDetailOpen] = useState(false);
395
+ const [sort, setSort] = useState<{ columnId: TweetSortColumnId; direction: TweetSortDirection }>({
396
+ columnId: "views",
397
+ direction: "desc",
398
+ });
399
+ const rows = useMemo(() => sortedTweets(data?.tweets ?? [], sort.columnId, sort.direction), [data?.tweets, sort]);
400
+ const columns = useMemo(() => buildTweetColumns(width), [width]);
401
+ const tableTickerTexts = useMemo(() => {
402
+ const seen = new Set<string>();
403
+ const texts: string[] = [];
404
+ for (const tweet of rows) {
405
+ for (const symbol of tweetTickers(tweet)) {
406
+ if (seen.has(symbol)) continue;
407
+ seen.add(symbol);
408
+ texts.push(`$${symbol}`);
409
+ }
410
+ }
411
+ return texts;
412
+ }, [rows]);
413
+ const { catalog: tickerCatalog, openTicker } = useInlineTickers(tableTickerTexts);
414
+ const selectedIndex = rows.findIndex((tweet) => tweet.id === selectedTweetId);
415
+ const activeIndex = selectedIndex >= 0 ? selectedIndex : rows.length > 0 ? 0 : -1;
416
+ const selectedTweet = rows[activeIndex] ?? null;
417
+
418
+ useEffect(() => {
419
+ if (rows.length === 0) {
420
+ if (selectedTweetId !== null) setSelectedTweetId(null);
421
+ setDetailOpen(false);
422
+ return;
423
+ }
424
+ if (!selectedTweetId || selectedIndex < 0) {
425
+ setSelectedTweetId(rows[0]!.id);
426
+ }
427
+ }, [rows, selectedIndex, selectedTweetId]);
428
+
429
+ const openSelectedTweet = useCallback(() => {
430
+ if (selectedTweet?.url) void rendererHost.openExternal(selectedTweet.url);
431
+ }, [rendererHost, selectedTweet]);
432
+
433
+ const handleHeaderClick = useCallback((columnId: string) => {
434
+ if (!isTweetSortColumnId(columnId)) return;
435
+ setSort((current) => (
436
+ current.columnId === columnId
437
+ ? { columnId, direction: current.direction === "desc" ? "asc" : "desc" }
438
+ : { columnId, direction: "desc" }
439
+ ));
440
+ }, []);
441
+
442
+ const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
443
+ if (event.name !== "r") return false;
444
+ event.preventDefault?.();
445
+ event.stopPropagation?.();
446
+ reload();
447
+ return true;
448
+ }, [reload]);
449
+
450
+ const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
451
+ if (event.name !== "o") return false;
452
+ event.preventDefault?.();
453
+ event.stopPropagation?.();
454
+ openSelectedTweet();
455
+ return true;
456
+ }, [openSelectedTweet]);
457
+
458
+ usePaneFooter(footerId, () => ({
459
+ info: [
460
+ ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
461
+ ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
462
+ ],
463
+ hints: [
464
+ { id: "refresh", key: "r", label: "efresh", onPress: reload },
465
+ ...(detailOpen ? [{ id: "open", key: "o", label: "pen", onPress: openSelectedTweet, disabled: !selectedTweet?.url }] : []),
466
+ ],
467
+ }), [detailOpen, error, footerId, loading, openSelectedTweet, reload, selectedTweet?.url]);
468
+
469
+ const renderCell = useCallback((
470
+ tweet: CloudTweetPayload,
471
+ column: TweetColumn,
472
+ _index: number,
473
+ rowState: { selected: boolean },
474
+ ): DataTableCell => {
475
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
476
+ switch (column.id) {
477
+ case "time":
478
+ return { text: formatRelativeShort(tweet.createdAt), color: selectedColor ?? colors.textDim };
479
+ case "author":
480
+ return {
481
+ text: `@${tweet.author.userName || tweet.author.name}`,
482
+ color: selectedColor ?? colors.textBright,
483
+ attributes: TextAttributes.BOLD,
484
+ };
485
+ case "text":
486
+ return { text: normalizeTweetCellText(tweet.text), color: selectedColor ?? colors.text };
487
+ case "tickers": {
488
+ const tickers = tweetTickers(tweet);
489
+ return {
490
+ text: tickers.map((ticker) => `$${ticker}`).join(" "),
491
+ content: (
492
+ <TickerBadgeList
493
+ symbols={tickers}
494
+ width={column.width}
495
+ catalog={tickerCatalog}
496
+ fallbackColor={selectedColor ?? colors.positive}
497
+ openTicker={openTicker}
498
+ />
499
+ ),
500
+ color: selectedColor ?? colors.positive,
501
+ };
502
+ }
503
+ case "likes":
504
+ return { text: formatMetric(tweet.metrics.likes), color: selectedColor ?? colors.textDim };
505
+ case "views":
506
+ return { text: formatMetric(tweet.metrics.views), color: selectedColor ?? colors.textDim };
507
+ }
508
+ }, [openTicker, tickerCatalog]);
509
+
510
+ const emptyContent = error && isAuthError(error)
511
+ ? <CloudAuthNotice message={error} showSignup />
512
+ : undefined;
513
+
514
+ return (
515
+ <DataTableStackView<CloudTweetPayload, TweetColumn>
516
+ focused={focused}
517
+ detailOpen={detailOpen}
518
+ onBack={() => setDetailOpen(false)}
519
+ detailTitle={selectedTweet ? `@${selectedTweet.author.userName || selectedTweet.author.name}` : "Tweet"}
520
+ detailContent={selectedTweet ? <TweetDetail tweet={selectedTweet} width={width} /> : null}
521
+ selectedIndex={activeIndex}
522
+ onSelectIndex={(index) => setSelectedTweetId(rows[index]?.id ?? null)}
523
+ onActivateIndex={(index) => {
524
+ const tweet = rows[index];
525
+ if (!tweet) return;
526
+ setSelectedTweetId(tweet.id);
527
+ setDetailOpen(true);
528
+ }}
529
+ onRootKeyDown={handleRootKeyDown}
530
+ onDetailKeyDown={handleDetailKeyDown}
531
+ rootWidth={width}
532
+ rootHeight={height}
533
+ columns={columns}
534
+ items={rows}
535
+ sortColumnId={sort.columnId}
536
+ sortDirection={sort.direction}
537
+ onHeaderClick={handleHeaderClick}
538
+ getItemKey={(tweet) => tweet.id}
539
+ isSelected={(tweet) => tweet.id === selectedTweetId}
540
+ onSelect={(tweet) => setSelectedTweetId(tweet.id)}
541
+ onActivate={(tweet) => {
542
+ setSelectedTweetId(tweet.id);
543
+ setDetailOpen(true);
544
+ }}
545
+ renderCell={renderCell}
546
+ emptyContent={emptyContent}
547
+ emptyStateTitle={loading ? "Loading tweets..." : error ?? "No tweets"}
548
+ emptyStateHint={data?.query}
549
+ showHorizontalScrollbar={false}
550
+ />
551
+ );
552
+ }
553
+
554
+ export function TwitterTickerTab({ focused, width, height }: DetailTabProps) {
555
+ const { symbol } = usePaneTicker();
556
+ const load = useCallback(() => {
557
+ if (!symbol) throw new Error("No ticker selected");
558
+ return apiClient.getCloudTickerTweets({
559
+ ticker: symbol,
560
+ hours: DEFAULT_TWEET_HOURS,
561
+ limit: DEFAULT_TWEET_LIMIT,
562
+ includeReplies: false,
563
+ });
564
+ }, [symbol]);
565
+
566
+ if (!symbol) {
567
+ return <EmptyState title="No ticker selected." />;
568
+ }
569
+
570
+ return (
571
+ <TweetSearchTable
572
+ focused={focused}
573
+ width={width}
574
+ height={height}
575
+ requestKey={`ticker:${symbol}`}
576
+ footerId="ticker-tweets"
577
+ load={load}
578
+ />
579
+ );
580
+ }
581
+
582
+ function TwitterFeedEditor({
583
+ editor,
584
+ focused,
585
+ width,
586
+ textareaRef,
587
+ onChange,
588
+ }: {
589
+ editor: FeedEditorState;
590
+ focused: boolean;
591
+ width: number;
592
+ textareaRef: RefObject<TextareaRenderable | null>;
593
+ onChange: (patch: Partial<FeedEditorState>) => void;
594
+ }) {
595
+ useEffect(() => {
596
+ if (focused) textareaRef.current?.focus?.();
597
+ }, [editor.key, focused, textareaRef]);
598
+
599
+ return (
600
+ <Box flexDirection="column" flexGrow={1} padding={1} gap={1}>
601
+ <Box height={1} flexDirection="row" gap={1}>
602
+ <Text fg={colors.textDim}>Mode</Text>
603
+ <SegmentedControl
604
+ value={editor.queryType}
605
+ options={[
606
+ { label: "Latest", value: "Latest" },
607
+ { label: "Top", value: "Top" },
608
+ ]}
609
+ onChange={(value) => onChange({ queryType: value === "Top" ? "Top" : "Latest" })}
610
+ />
611
+ </Box>
612
+ <Box flexGrow={1} minHeight={5} border borderColor={colors.border} backgroundColor={colors.panel}>
613
+ <Textarea
614
+ key={editor.key}
615
+ ref={textareaRef}
616
+ initialValue={editor.query}
617
+ placeholder="$AAPL -filter:replies"
618
+ focused={focused}
619
+ textColor={colors.text}
620
+ placeholderColor={colors.textDim}
621
+ backgroundColor={colors.panel}
622
+ flexGrow={1}
623
+ wrapText
624
+ />
625
+ </Box>
626
+ {editor.error ? (
627
+ <Text fg={colors.negative}>{editor.error}</Text>
628
+ ) : (
629
+ <Text fg={colors.textDim}>{truncateWithEllipsis("Use X advanced search syntax. Example: $AAPL OR $MSFT -filter:replies", Math.max(1, width - 2))}</Text>
630
+ )}
631
+ </Box>
632
+ );
633
+ }
634
+
635
+ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
636
+ const paneId = usePaneInstanceId();
637
+ const paneInstance = usePaneInstance();
638
+ const [persistedState, setPersistedState] = usePluginState<PersistedTwitterFeedState>(
639
+ `twitter-feed:${paneId}`,
640
+ EMPTY_FEED_STATE,
641
+ { schemaVersion: TWEET_SEARCH_SCHEMA_VERSION },
642
+ );
643
+ const [activeFeedId, setActiveFeedId] = usePluginPaneState<string | null>("activeFeedId", null);
644
+ const [editorState, setEditorState] = useState<FeedEditorState | null>(null);
645
+ const textareaRef = useRef<TextareaRenderable | null>(null);
646
+ const initializedRef = useRef(false);
647
+ const feeds = useMemo(() => normalizeFeeds(persistedState), [persistedState]);
648
+
649
+ const updateFeeds = useCallback((updater: (feeds: TwitterFeed[]) => TwitterFeed[]) => {
650
+ setPersistedState((current) => ({ feeds: updater(normalizeFeeds(current)) }));
651
+ }, [setPersistedState]);
652
+
653
+ useEffect(() => {
654
+ if (initializedRef.current) return;
655
+ initializedRef.current = true;
656
+ if (feeds.length > 0) return;
657
+ const seedQuery = typeof paneInstance?.params?.query === "string" ? paneInstance.params.query.trim() : "";
658
+ if (!seedQuery) return;
659
+ const seedType = paneInstance?.params?.queryType === "Top" ? "Top" : "Latest";
660
+ const feed = createFeed(seedQuery, seedType);
661
+ setPersistedState({ feeds: [feed] });
662
+ setActiveFeedId(feed.id);
663
+ }, [feeds.length, paneInstance?.params?.query, paneInstance?.params?.queryType, setActiveFeedId, setPersistedState]);
664
+
665
+ useEffect(() => {
666
+ if (feeds.length === 0) {
667
+ if (activeFeedId !== null) setActiveFeedId(null);
668
+ return;
669
+ }
670
+ if (!activeFeedId || !feeds.some((feed) => feed.id === activeFeedId)) {
671
+ setActiveFeedId(feeds[0]!.id);
672
+ }
673
+ }, [activeFeedId, feeds, setActiveFeedId]);
674
+
675
+ const activeFeed = feeds.find((feed) => feed.id === activeFeedId) ?? feeds[0] ?? null;
676
+
677
+ const openCreateEditor = useCallback(() => {
678
+ setEditorState({
679
+ mode: "create",
680
+ feedId: null,
681
+ query: "",
682
+ queryType: "Latest",
683
+ key: generateEditorKey(),
684
+ error: null,
685
+ });
686
+ }, []);
687
+
688
+ const openEditEditor = useCallback((feed: TwitterFeed | null) => {
689
+ if (!feed) return;
690
+ setEditorState({
691
+ mode: "edit",
692
+ feedId: feed.id,
693
+ query: feed.query,
694
+ queryType: feed.queryType,
695
+ key: generateEditorKey(),
696
+ error: null,
697
+ });
698
+ }, []);
699
+
700
+ const closeEditor = useCallback(() => {
701
+ textareaRef.current = null;
702
+ setEditorState(null);
703
+ }, []);
704
+
705
+ const saveEditor = useCallback(() => {
706
+ if (!editorState) return;
707
+ const query = textareaRef.current?.editBuffer.getText().trim() || editorState.query.trim();
708
+ if (!query) {
709
+ setEditorState((current) => current ? { ...current, error: "Query is required." } : current);
710
+ return;
711
+ }
712
+
713
+ if (editorState.mode === "create") {
714
+ const feed = createFeed(query, editorState.queryType);
715
+ updateFeeds((current) => [...current, feed]);
716
+ setActiveFeedId(feed.id);
717
+ } else if (editorState.feedId) {
718
+ const now = Date.now();
719
+ updateFeeds((current) => current.map((feed) => (
720
+ feed.id === editorState.feedId
721
+ ? {
722
+ ...feed,
723
+ query,
724
+ queryType: editorState.queryType,
725
+ title: deriveFeedTitle(query),
726
+ updatedAt: now,
727
+ lastError: null,
728
+ }
729
+ : feed
730
+ )));
731
+ }
732
+
733
+ closeEditor();
734
+ }, [closeEditor, editorState, setActiveFeedId, updateFeeds]);
735
+
736
+ const removeFeed = useCallback((feedId: string) => {
737
+ updateFeeds((current) => current.filter((feed) => feed.id !== feedId));
738
+ if (activeFeedId === feedId) {
739
+ const next = feeds.filter((feed) => feed.id !== feedId)[0] ?? null;
740
+ setActiveFeedId(next?.id ?? null);
741
+ }
742
+ }, [activeFeedId, feeds, setActiveFeedId, updateFeeds]);
743
+
744
+ const cycleFeeds = useCallback((direction: -1 | 1) => {
745
+ if (!activeFeed || feeds.length <= 1) return;
746
+ const index = feeds.findIndex((feed) => feed.id === activeFeed.id);
747
+ const nextIndex = (index + direction + feeds.length) % feeds.length;
748
+ setActiveFeedId(feeds[nextIndex]!.id);
749
+ }, [activeFeed, feeds, setActiveFeedId]);
750
+
751
+ useShortcut((event) => {
752
+ if (!focused) return;
753
+
754
+ if (editorState) {
755
+ if (event.name === "escape") {
756
+ event.preventDefault?.();
757
+ event.stopPropagation?.();
758
+ closeEditor();
759
+ }
760
+ if (event.ctrl && event.name === "s") {
761
+ event.preventDefault?.();
762
+ event.stopPropagation?.();
763
+ saveEditor();
764
+ }
765
+ return;
766
+ }
767
+
768
+ if (event.name === "n") {
769
+ event.preventDefault?.();
770
+ event.stopPropagation?.();
771
+ openCreateEditor();
772
+ return;
773
+ }
774
+ if (event.name === "e") {
775
+ event.preventDefault?.();
776
+ event.stopPropagation?.();
777
+ openEditEditor(activeFeed);
778
+ return;
779
+ }
780
+ if (event.name === "d" && activeFeed) {
781
+ event.preventDefault?.();
782
+ event.stopPropagation?.();
783
+ removeFeed(activeFeed.id);
784
+ return;
785
+ }
786
+ if (event.name === "[") {
787
+ event.preventDefault?.();
788
+ event.stopPropagation?.();
789
+ cycleFeeds(-1);
790
+ return;
791
+ }
792
+ if (event.name === "]") {
793
+ event.preventDefault?.();
794
+ event.stopPropagation?.();
795
+ cycleFeeds(1);
796
+ }
797
+ });
798
+
799
+ const activeFeedIdValue = activeFeed?.id ?? null;
800
+ const activeFeedQuery = activeFeed?.query ?? null;
801
+ const activeFeedQueryType = activeFeed?.queryType ?? "Latest";
802
+ const loadActiveFeed = useCallback(() => {
803
+ if (!activeFeedQuery) throw new Error("No X feed selected");
804
+ return apiClient.searchCloudTweets({
805
+ query: activeFeedQuery,
806
+ queryType: activeFeedQueryType,
807
+ hours: DEFAULT_TWEET_HOURS,
808
+ limit: DEFAULT_TWEET_LIMIT,
809
+ });
810
+ }, [activeFeedQuery, activeFeedQueryType]);
811
+
812
+ const markFeedResult = useCallback((result: CloudTweetSearchResponse) => {
813
+ if (!activeFeedIdValue) return;
814
+ const now = Date.now();
815
+ updateFeeds((current) => current.map((feed) => (
816
+ feed.id === activeFeedIdValue
817
+ ? { ...feed, lastSuccessAt: now, lastError: null, title: deriveFeedTitle(result.query) }
818
+ : feed
819
+ )));
820
+ }, [activeFeedIdValue, updateFeeds]);
821
+
822
+ const markFeedError = useCallback((message: string) => {
823
+ if (!activeFeedIdValue) return;
824
+ updateFeeds((current) => current.map((feed) => (
825
+ feed.id === activeFeedIdValue ? { ...feed, lastError: message } : feed
826
+ )));
827
+ }, [activeFeedIdValue, updateFeeds]);
828
+
829
+ usePaneFooter("twitter-feed", () => ({
830
+ info: activeFeed
831
+ ? [
832
+ { id: "mode", parts: [{ text: activeFeed.queryType, tone: "value" }] },
833
+ ...(activeFeed.lastSuccessAt ? [{ id: "last", parts: [{ text: `ran ${formatTimeAgo(new Date(activeFeed.lastSuccessAt))}`, tone: "muted" as const }] }] : []),
834
+ ]
835
+ : [],
836
+ hints: editorState
837
+ ? [
838
+ { id: "save", key: "Ctrl+S", label: "save", onPress: saveEditor },
839
+ { id: "cancel", key: "Esc", label: "cancel", onPress: closeEditor },
840
+ ]
841
+ : [
842
+ { id: "new", key: "n", label: "ew", onPress: openCreateEditor },
843
+ { id: "edit", key: "e", label: "dit", onPress: () => openEditEditor(activeFeed), disabled: !activeFeed },
844
+ { id: "delete", key: "d", label: "elete", onPress: activeFeed ? () => removeFeed(activeFeed.id) : undefined, disabled: !activeFeed },
845
+ ],
846
+ }), [activeFeed, closeEditor, editorState, openCreateEditor, openEditEditor, removeFeed, saveEditor]);
847
+
848
+ return (
849
+ <Box flexDirection="column" width={width} height={height}>
850
+ <Box height={1}>
851
+ <Tabs
852
+ tabs={feeds.map((feed) => ({
853
+ label: truncateWithEllipsis(feed.title, 18),
854
+ value: feed.id,
855
+ onClose: editorState ? undefined : removeFeed,
856
+ onDoubleClick: (id) => openEditEditor(feeds.find((feed) => feed.id === id) ?? null),
857
+ }))}
858
+ activeValue={activeFeed?.id ?? null}
859
+ onSelect={(id) => {
860
+ if (editorState) return;
861
+ setActiveFeedId(id);
862
+ }}
863
+ compact
864
+ variant="pill"
865
+ closeMode="active"
866
+ onAdd={editorState ? undefined : openCreateEditor}
867
+ />
868
+ </Box>
869
+
870
+ {editorState ? (
871
+ <TwitterFeedEditor
872
+ editor={editorState}
873
+ focused={focused}
874
+ width={width}
875
+ textareaRef={textareaRef}
876
+ onChange={(patch) => setEditorState((current) => current ? { ...current, ...patch, error: null } : current)}
877
+ />
878
+ ) : !activeFeed ? (
879
+ <Box padding={1} flexGrow={1}>
880
+ <EmptyState title="No X feeds yet." hint="Use + or TWIT <query>." />
881
+ </Box>
882
+ ) : (
883
+ <TweetSearchTable
884
+ focused={focused}
885
+ width={width}
886
+ height={Math.max(1, height - 1)}
887
+ requestKey={`feed:${activeFeed.id}:${activeFeed.query}:${activeFeed.queryType}`}
888
+ footerId="twitter-feed-search"
889
+ load={loadActiveFeed}
890
+ onResult={markFeedResult}
891
+ onError={markFeedError}
892
+ />
893
+ )}
894
+ </Box>
895
+ );
896
+ }