gloomberb 0.2.0 → 0.3.2

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 (311) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1365 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1546 -0
  45. package/src/components/command-bar/command-bar.tsx +4841 -0
  46. package/src/components/command-bar/command-registry.ts +269 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +151 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +651 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/markdown-text.tsx +218 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +347 -0
  97. package/src/data/config-store.ts +507 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +55 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +350 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +460 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +270 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +224 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +162 -0
  259. package/src/state/app-context.tsx +981 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +444 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +282 -0
  285. package/src/updater.ts +303 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -68
@@ -0,0 +1,73 @@
1
+ import {
2
+ cloneLayout,
3
+ clonePaneSettings,
4
+ findPaneInstance,
5
+ type LayoutConfig,
6
+ type PaneInstanceConfig,
7
+ } from "./types/config";
8
+
9
+ export function getPaneSettings(instance: PaneInstanceConfig | null | undefined): Record<string, unknown> {
10
+ return clonePaneSettings(instance?.settings) ?? {};
11
+ }
12
+
13
+ export function getPaneSettingValue<T>(
14
+ instance: PaneInstanceConfig | null | undefined,
15
+ key: string,
16
+ fallback: T,
17
+ ): T {
18
+ return (instance?.settings?.[key] as T | undefined) ?? fallback;
19
+ }
20
+
21
+ export function updatePaneInstance(
22
+ layout: LayoutConfig,
23
+ paneId: string,
24
+ updater: (instance: PaneInstanceConfig) => PaneInstanceConfig,
25
+ ): LayoutConfig {
26
+ const existing = findPaneInstance(layout, paneId);
27
+ if (!existing) return layout;
28
+
29
+ const nextLayout = cloneLayout(layout);
30
+ const index = nextLayout.instances.findIndex((instance) => instance.instanceId === paneId);
31
+ if (index < 0) return layout;
32
+
33
+ nextLayout.instances[index] = updater(nextLayout.instances[index]!);
34
+ return nextLayout;
35
+ }
36
+
37
+ export function setPaneSettings(
38
+ layout: LayoutConfig,
39
+ paneId: string,
40
+ settings: Record<string, unknown> | undefined,
41
+ ): LayoutConfig {
42
+ return updatePaneInstance(layout, paneId, (instance) => ({
43
+ ...instance,
44
+ settings: clonePaneSettings(settings),
45
+ }));
46
+ }
47
+
48
+ export function setPaneSetting(
49
+ layout: LayoutConfig,
50
+ paneId: string,
51
+ key: string,
52
+ value: unknown,
53
+ ): LayoutConfig {
54
+ const current = findPaneInstance(layout, paneId);
55
+ if (!current) return layout;
56
+ return setPaneSettings(layout, paneId, {
57
+ ...(current.settings ?? {}),
58
+ [key]: value,
59
+ });
60
+ }
61
+
62
+ export function deletePaneSetting(
63
+ layout: LayoutConfig,
64
+ paneId: string,
65
+ key: string,
66
+ ): LayoutConfig {
67
+ const current = findPaneInstance(layout, paneId);
68
+ if (!current?.settings || !(key in current.settings)) return layout;
69
+
70
+ const nextSettings = { ...current.settings };
71
+ delete nextSettings[key];
72
+ return setPaneSettings(layout, paneId, Object.keys(nextSettings).length > 0 ? nextSettings : undefined);
73
+ }
@@ -0,0 +1,350 @@
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { TextAttributes } from "@opentui/core";
4
+ import type { InputRenderable, ScrollBoxRenderable } from "@opentui/core";
5
+ import type { DetailTabProps } from "../../../types/plugin";
6
+ import { useAppState, usePaneTicker } from "../../../state/app-context";
7
+ import { useFxRatesMap } from "../../../market-data/hooks";
8
+ import { usePluginState } from "../../../plugins/plugin-runtime";
9
+ import { useInlineTickers } from "../../../state/use-inline-tickers";
10
+ import { MarkdownText } from "../../../components/markdown-text";
11
+ import { colors } from "../../../theme/colors";
12
+ import { Spinner } from "../../../components/spinner";
13
+ import { buildTickerAiContext } from "./ticker-context";
14
+ import { detectProviders, getAvailableProviders, resolveDefaultAiProviderId, __setDetectedProvidersForTests, type AiProvider } from "./providers";
15
+ import { runAiPrompt } from "./runner";
16
+ import { truncateWithEllipsis } from "./utils";
17
+
18
+ interface ChatMessage {
19
+ role: "user" | "assistant";
20
+ content: string;
21
+ loading?: boolean;
22
+ }
23
+
24
+ interface PersistedConversation {
25
+ updatedAt: number;
26
+ messages: ChatMessage[];
27
+ }
28
+
29
+ const ASK_AI_HISTORY_LIMIT = 40;
30
+ const ASK_AI_RETENTION_MS = 90 * 24 * 60 * 60_000;
31
+ const chatHistories = new Map<string, ChatMessage[]>();
32
+
33
+ export { __setDetectedProvidersForTests };
34
+ export type { AiProvider };
35
+
36
+ export function __setAskAiHistoryForTests(symbol: string, messages: ChatMessage[]): void {
37
+ chatHistories.set(symbol, messages);
38
+ }
39
+
40
+ export function __resetAskAiHistoryForTests(): void {
41
+ chatHistories.clear();
42
+ }
43
+
44
+ function getProviderHeaderParts(providerName: string, canSwitch: boolean, width: number): { prefix: string; label: string } {
45
+ if (width <= 0) return { prefix: "", label: "" };
46
+
47
+ const variants = [
48
+ { prefix: "Provider: ", label: canSwitch ? `${providerName} (t to switch)` : providerName },
49
+ { prefix: "Provider: ", label: canSwitch ? `${providerName} (t)` : providerName },
50
+ { prefix: "", label: canSwitch ? `${providerName} (t to switch)` : providerName },
51
+ { prefix: "", label: canSwitch ? `${providerName} (t)` : providerName },
52
+ { prefix: "", label: providerName },
53
+ ];
54
+
55
+ for (const variant of variants) {
56
+ if (variant.prefix.length + variant.label.length <= width) {
57
+ return variant;
58
+ }
59
+ }
60
+
61
+ return { prefix: "", label: truncateWithEllipsis(providerName, width) };
62
+ }
63
+
64
+ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabProps) {
65
+ const { state } = useAppState();
66
+ const { ticker, financials } = usePaneTicker();
67
+ const exchangeRates = useFxRatesMap([
68
+ state.config.baseCurrency,
69
+ ticker?.metadata.currency,
70
+ financials?.quote?.currency,
71
+ ...(ticker?.metadata.positions.map((position) => position.currency) ?? []),
72
+ ]);
73
+ const effectiveExchangeRates = exchangeRates.size > 1 || state.exchangeRates.size === 0
74
+ ? exchangeRates
75
+ : state.exchangeRates;
76
+ const [providers] = useState(() => detectProviders());
77
+ const defaultProviderId = resolveDefaultAiProviderId(providers);
78
+ const [providerId, setProviderId] = usePluginState<string>("providerId", defaultProviderId);
79
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
80
+ const [inputFocused, setInputFocused] = useState(false);
81
+ const [inputValue, setInputValue] = useState("");
82
+ const inputRef = useRef<InputRenderable>(null);
83
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
84
+ const runRef = useRef<ReturnType<typeof runAiPrompt> | null>(null);
85
+ const availableProviders = getAvailableProviders(providers);
86
+ const currentProvider = providers.find((provider) => provider.id === providerId && provider.available)
87
+ ?? providers.find((provider) => provider.id === defaultProviderId)
88
+ ?? providers[0];
89
+
90
+ const tickerSymbol = ticker?.metadata.ticker ?? null;
91
+ const conversationKey = tickerSymbol && currentProvider
92
+ ? `conversation:${currentProvider.id}:${tickerSymbol}`
93
+ : "__conversation:none__";
94
+ const [persistedConversation, setPersistedConversation] = usePluginState<PersistedConversation | null>(
95
+ conversationKey,
96
+ null,
97
+ { schemaVersion: 1 },
98
+ );
99
+
100
+ useEffect(() => {
101
+ if (!tickerSymbol) {
102
+ setMessages((previous) => (previous.length === 0 ? previous : []));
103
+ return;
104
+ }
105
+
106
+ if (persistedConversation && Date.now() - persistedConversation.updatedAt <= ASK_AI_RETENTION_MS) {
107
+ chatHistories.set(tickerSymbol, persistedConversation.messages);
108
+ }
109
+
110
+ const nextMessages = chatHistories.get(tickerSymbol) ?? [];
111
+ setMessages((previous) => (
112
+ previous.length === nextMessages.length && previous.every((message, index) => message === nextMessages[index])
113
+ ? previous
114
+ : nextMessages
115
+ ));
116
+ }, [persistedConversation, tickerSymbol]);
117
+
118
+ useEffect(() => {
119
+ if (!tickerSymbol || !currentProvider || messages.length === 0) return;
120
+ const hasLoading = messages.some((message) => message.loading);
121
+ if (hasLoading) {
122
+ chatHistories.set(tickerSymbol, messages);
123
+ return;
124
+ }
125
+ const trimmed = messages.slice(-ASK_AI_HISTORY_LIMIT);
126
+ chatHistories.set(tickerSymbol, trimmed);
127
+ setPersistedConversation({
128
+ updatedAt: Date.now(),
129
+ messages: trimmed,
130
+ });
131
+ }, [currentProvider, messages, setPersistedConversation, tickerSymbol]);
132
+
133
+ useEffect(() => {
134
+ const sb = scrollRef.current;
135
+ if (!sb || messages.length === 0) return;
136
+ sb.scrollTo({ x: 0, y: Math.max(0, sb.scrollHeight - sb.viewport.height) });
137
+ }, [messages, focused]);
138
+
139
+ const cycleProvider = useCallback(() => {
140
+ if (availableProviders.length <= 1) return;
141
+ setProviderId((current) => {
142
+ const currentIndex = providers.findIndex((provider) => provider.id === current);
143
+ let nextIndex = currentIndex >= 0 ? (currentIndex + 1) % providers.length : 0;
144
+ while (!providers[nextIndex]?.available) {
145
+ nextIndex = (nextIndex + 1) % providers.length;
146
+ }
147
+ return providers[nextIndex]?.id ?? current;
148
+ });
149
+ }, [availableProviders.length, providers, setProviderId]);
150
+
151
+ const focusInput = useCallback(() => {
152
+ setInputFocused(true);
153
+ onCapture(true);
154
+ inputRef.current?.focus();
155
+ }, [onCapture]);
156
+
157
+ const blurInput = useCallback(() => {
158
+ setInputFocused(false);
159
+ onCapture(false);
160
+ }, [onCapture]);
161
+
162
+ const sendMessage = useCallback(async (text: string) => {
163
+ if (!ticker || !currentProvider?.available || runRef.current) return;
164
+
165
+ const userMessage: ChatMessage = { role: "user", content: text };
166
+ const assistantMessage: ChatMessage = { role: "assistant", content: "", loading: true };
167
+
168
+ setMessages((previous) => [...previous, userMessage, assistantMessage]);
169
+
170
+ const context = buildTickerAiContext(
171
+ ticker,
172
+ financials,
173
+ state.config.baseCurrency,
174
+ effectiveExchangeRates,
175
+ );
176
+ const fullPrompt = `You are a financial analyst assistant. Here is the current financial data for the company being discussed:\n\n${context}\n\nUser question: ${text}`;
177
+
178
+ try {
179
+ const run = runAiPrompt({
180
+ provider: currentProvider,
181
+ prompt: fullPrompt,
182
+ onChunk: (output) => {
183
+ setMessages((previous) => {
184
+ const updated = [...previous];
185
+ updated[updated.length - 1] = { role: "assistant", content: output, loading: true };
186
+ return updated;
187
+ });
188
+ },
189
+ });
190
+ runRef.current = run;
191
+ const output = await run.done;
192
+ setMessages((previous) => {
193
+ const updated = [...previous];
194
+ updated[updated.length - 1] = {
195
+ role: "assistant",
196
+ content: output,
197
+ loading: false,
198
+ };
199
+ return updated;
200
+ });
201
+ } catch (error: any) {
202
+ setMessages((previous) => {
203
+ const updated = [...previous];
204
+ updated[updated.length - 1] = {
205
+ role: "assistant",
206
+ content: `Error: ${error?.message || "Failed to run AI command"}`,
207
+ loading: false,
208
+ };
209
+ return updated;
210
+ });
211
+ } finally {
212
+ runRef.current = null;
213
+ }
214
+ }, [currentProvider, effectiveExchangeRates, financials, state.config.baseCurrency, ticker]);
215
+
216
+ useKeyboard((event) => {
217
+ if (!focused) return;
218
+
219
+ const isEnter = event.name === "enter" || event.name === "return";
220
+ if (!inputFocused) {
221
+ if (isEnter) {
222
+ focusInput();
223
+ return;
224
+ }
225
+ if (event.name === "t" || event.name === "T") {
226
+ cycleProvider();
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (event.name === "escape") {
232
+ blurInput();
233
+ }
234
+ });
235
+
236
+ useEffect(() => () => {
237
+ runRef.current?.cancel();
238
+ }, []);
239
+
240
+ const { catalog, openTicker } = useInlineTickers(messages.map((message) => message.content));
241
+
242
+ if (!ticker) return <text fg={colors.textDim}>Select a ticker to ask AI.</text>;
243
+
244
+ if (availableProviders.length === 0) {
245
+ return (
246
+ <box flexDirection="column" padding={1} flexGrow={1}>
247
+ <box height={1}>
248
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Ask AI</text>
249
+ </box>
250
+ <box height={1} />
251
+ <text fg={colors.textDim}>No AI CLI tools detected. Install one of:</text>
252
+ <box height={1} />
253
+ <text fg={colors.text}> claude - Claude Code (claude.ai/claude-code)</text>
254
+ <text fg={colors.text}> gemini - Gemini CLI (github.com/google-gemini/gemini-cli)</text>
255
+ <text fg={colors.text}> codex - OpenAI Codex (github.com/openai/codex)</text>
256
+ </box>
257
+ );
258
+ }
259
+
260
+ const contentWidth = Math.max(width - 2, 0);
261
+ const dividerWidth = Math.max(contentWidth, 0);
262
+ const chatHeight = Math.max(height - 7, 4);
263
+ const providerHeader = getProviderHeaderParts(
264
+ currentProvider?.name || "None",
265
+ availableProviders.length > 1,
266
+ Math.max(contentWidth - "Ask AI".length - 1, 0),
267
+ );
268
+
269
+ return (
270
+ <box flexDirection="column" paddingX={1} paddingTop={1} height={height - 2}>
271
+ <box flexDirection="row" height={1}>
272
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Ask AI</text>
273
+ <box flexGrow={1} />
274
+ <box onMouseDown={cycleProvider}>
275
+ <text fg={colors.textMuted}>
276
+ {providerHeader.prefix}
277
+ <span fg={colors.textBright}>{providerHeader.label}</span>
278
+ </text>
279
+ </box>
280
+ </box>
281
+
282
+ <scrollbox ref={scrollRef} height={chatHeight} scrollY>
283
+ <box flexDirection="column">
284
+ {messages.length === 0 ? (
285
+ <box paddingTop={1}>
286
+ <text fg={colors.textDim}>
287
+ Ask questions about {ticker.metadata.ticker}. Financial data will be included as context.
288
+ </text>
289
+ </box>
290
+ ) : (
291
+ messages.map((message, index) => (
292
+ <box key={index} flexDirection="column" paddingTop={index > 0 ? 1 : 0}>
293
+ <box height={1}>
294
+ <text
295
+ attributes={TextAttributes.BOLD}
296
+ fg={message.role === "user" ? colors.textBright : colors.positive}
297
+ >
298
+ {message.role === "user" ? "You" : currentProvider?.name || "AI"}
299
+ {message.loading ? " (thinking...)" : ""}
300
+ </text>
301
+ </box>
302
+ <box>
303
+ {message.content ? (
304
+ <MarkdownText
305
+ text={message.content}
306
+ lineWidth={contentWidth}
307
+ catalog={catalog}
308
+ textColor={colors.text}
309
+ openTicker={openTicker}
310
+ />
311
+ ) : message.loading ? (
312
+ <Spinner label="Generating..." />
313
+ ) : (
314
+ <text fg={colors.text}>{""}</text>
315
+ )}
316
+ </box>
317
+ </box>
318
+ ))
319
+ )}
320
+ </box>
321
+ </scrollbox>
322
+
323
+ <box height={1}>
324
+ <text fg={colors.textDim}>{"\u2500".repeat(dividerWidth)}</text>
325
+ </box>
326
+
327
+ <box flexDirection="row" height={1} onMouseDown={focusInput}>
328
+ <text fg={colors.textMuted}>{"> "}</text>
329
+ <box flexGrow={1}>
330
+ <input
331
+ ref={inputRef}
332
+ placeholder={inputFocused ? "Ask a question..." : "Enter to start typing"}
333
+ focused={inputFocused && focused}
334
+ textColor={colors.text}
335
+ placeholderColor={colors.textDim}
336
+ backgroundColor={inputFocused && focused ? colors.panel : colors.bg}
337
+ onInput={(value) => setInputValue(value)}
338
+ onChange={(value) => setInputValue(value)}
339
+ onSubmit={() => {
340
+ if (!inputValue.trim()) return;
341
+ void sendMessage(inputValue.trim());
342
+ setInputValue("");
343
+ (inputRef.current as any)?.editBuffer?.setText?.("");
344
+ }}
345
+ />
346
+ </box>
347
+ </box>
348
+ </box>
349
+ );
350
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { execSync } from "child_process";
4
+
5
+ export interface GloomberbCliCommand {
6
+ argv: string[];
7
+ display: string;
8
+ mode: "installed" | "bun-source" | "unavailable";
9
+ }
10
+
11
+ function commandExists(command: string): boolean {
12
+ try {
13
+ if (typeof Bun !== "undefined" && typeof Bun.which === "function") {
14
+ return !!Bun.which(command);
15
+ }
16
+ } catch {
17
+ // Fall back to shell lookup when Bun.which is unavailable.
18
+ }
19
+
20
+ try {
21
+ execSync(`command -v ${command}`, { stdio: "ignore" });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export function resolveGloomberbCliCommand({
29
+ cwd = process.cwd(),
30
+ hasCommand = commandExists,
31
+ fileExists = existsSync,
32
+ }: {
33
+ cwd?: string;
34
+ hasCommand?: (command: string) => boolean;
35
+ fileExists?: (path: string) => boolean;
36
+ } = {}): GloomberbCliCommand {
37
+ if (hasCommand("gloomberb")) {
38
+ return {
39
+ argv: ["gloomberb"],
40
+ display: "gloomberb",
41
+ mode: "installed",
42
+ };
43
+ }
44
+
45
+ const sourceEntry = resolve(cwd, "src", "index.tsx");
46
+ if (fileExists(sourceEntry)) {
47
+ return {
48
+ argv: ["bun", sourceEntry],
49
+ display: `bun ${sourceEntry}`,
50
+ mode: "bun-source",
51
+ };
52
+ }
53
+
54
+ return {
55
+ argv: [],
56
+ display: "gloomberb",
57
+ mode: "unavailable",
58
+ };
59
+ }
60
+
61
+ export function buildGloomberbCliInstructions(command = resolveGloomberbCliCommand()): string[] {
62
+ const base = command.display;
63
+ return [
64
+ `The local Gloomberb CLI is available as: ${base}`,
65
+ `Useful commands: ${base} help`,
66
+ `Inspect a ticker with: ${base} ticker <symbol>`,
67
+ `Inspect a portfolio or watchlist with: ${base} portfolio [name]`,
68
+ "The CLI does not search for unknown companies, so use it to validate ticker candidates before returning them.",
69
+ ];
70
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDefaultConfig } from "../../../types/config";
3
+ import type { GloomPluginContext, PaneDef, PaneTemplateDef, DetailTabDef } from "../../../types/plugin";
4
+ import { aiPlugin } from "./index";
5
+ import { __setDetectedProvidersForTests, type AiProvider } from "./providers";
6
+
7
+ function createPluginContext() {
8
+ const panes: PaneDef[] = [];
9
+ const templates: PaneTemplateDef[] = [];
10
+ const detailTabs: DetailTabDef[] = [];
11
+
12
+ const ctx = {
13
+ registerPane: (pane: PaneDef) => { panes.push(pane); },
14
+ registerPaneTemplate: (template: PaneTemplateDef) => { templates.push(template); },
15
+ registerCommand: () => {},
16
+ registerColumn: () => {},
17
+ registerBroker: () => {},
18
+ registerDataProvider: () => {},
19
+ registerDetailTab: (tab: DetailTabDef) => { detailTabs.push(tab); },
20
+ registerShortcut: () => {},
21
+ registerTickerAction: () => {},
22
+ getData: () => null,
23
+ getTicker: () => null,
24
+ getConfig: () => createDefaultConfig("/tmp/gloomberb-ai-plugin"),
25
+ dataProvider: {} as any,
26
+ tickerRepository: {} as any,
27
+ persistence: {} as any,
28
+ log: { info() {}, warn() {}, error() {}, debug() {} },
29
+ resume: {
30
+ getState: () => null,
31
+ setState: () => {},
32
+ deleteState: () => {},
33
+ getPaneState: () => null,
34
+ setPaneState: () => {},
35
+ deletePaneState: () => {},
36
+ },
37
+ configState: {
38
+ get: () => null,
39
+ set: async () => {},
40
+ delete: async () => {},
41
+ keys: () => [],
42
+ },
43
+ paneSettings: {
44
+ get: () => null,
45
+ set: async () => {},
46
+ delete: async () => {},
47
+ },
48
+ createBrokerInstance: async () => { throw new Error("unused"); },
49
+ updateBrokerInstance: async () => {},
50
+ syncBrokerInstance: async () => {},
51
+ removeBrokerInstance: async () => {},
52
+ selectTicker: () => {},
53
+ switchPanel: () => {},
54
+ switchTab: () => {},
55
+ openCommandBar: () => {},
56
+ showPane: () => {},
57
+ createPaneFromTemplate: () => {},
58
+ hidePane: () => {},
59
+ focusPane: () => {},
60
+ pinTicker: () => {},
61
+ openPaneSettings: () => {},
62
+ on: () => () => {},
63
+ emit: () => {},
64
+ showWidget: () => {},
65
+ hideWidget: () => {},
66
+ showToast: () => {},
67
+ } satisfies GloomPluginContext;
68
+
69
+ return { ctx, panes, templates, detailTabs };
70
+ }
71
+
72
+ describe("aiPlugin", () => {
73
+ test("registers the Ask AI detail tab and AI screener pane template", async () => {
74
+ const providers: AiProvider[] = [
75
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
76
+ { id: "gemini", name: "Gemini", command: "gemini", available: false, buildArgs: () => [] },
77
+ ];
78
+ __setDetectedProvidersForTests(providers);
79
+
80
+ const { ctx, panes, templates, detailTabs } = createPluginContext();
81
+ await aiPlugin.setup?.(ctx);
82
+
83
+ const detailTab = detailTabs.find((entry) => entry.id === "ai-chat");
84
+ const pane = panes.find((entry) => entry.id === "ai-screener");
85
+ const template = templates.find((entry) => entry.id === "new-ai-screener-pane");
86
+
87
+ expect(detailTab?.name).toBe("Ask AI");
88
+ expect(pane?.name).toBe("AI Screener");
89
+ expect(pane?.settings?.({
90
+ config: createDefaultConfig("/tmp/gloomberb-ai-plugin"),
91
+ layout: createDefaultConfig("/tmp/gloomberb-ai-plugin").layout,
92
+ paneId: "ai-screener:test",
93
+ paneType: "ai-screener",
94
+ pane: {
95
+ instanceId: "ai-screener:test",
96
+ paneId: "ai-screener",
97
+ settings: {},
98
+ },
99
+ settings: {},
100
+ paneState: {},
101
+ activeTicker: null,
102
+ activeCollectionId: null,
103
+ })?.fields[0]?.key).toBe("columnIds");
104
+ expect(template?.shortcut).toEqual({
105
+ prefix: "AI",
106
+ argPlaceholder: "prompt",
107
+ argKind: "text",
108
+ });
109
+ expect(template?.wizard?.map((step) => step.type)).toEqual(["select", "textarea"]);
110
+ expect(template?.wizard?.[0]?.defaultValue).toBe("claude");
111
+ expect(template?.wizard?.[0]?.options).toEqual([
112
+ { label: "Claude", value: "claude" },
113
+ ]);
114
+ expect(template?.createInstance?.(
115
+ {
116
+ config: createDefaultConfig("/tmp/gloomberb-ai-plugin"),
117
+ layout: createDefaultConfig("/tmp/gloomberb-ai-plugin").layout,
118
+ focusedPaneId: null,
119
+ activeTicker: null,
120
+ activeCollectionId: null,
121
+ },
122
+ {
123
+ values: {
124
+ providerId: "claude",
125
+ prompt: "high-ROIC compounders",
126
+ },
127
+ },
128
+ )).toEqual({
129
+ title: "AI Screener",
130
+ placement: "floating",
131
+ params: {
132
+ prompt: "high-ROIC compounders",
133
+ providerId: "claude",
134
+ },
135
+ });
136
+
137
+ __setDetectedProvidersForTests(null);
138
+ });
139
+ });