gloomberb 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1330 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  45. package/src/components/command-bar/command-bar.tsx +4823 -0
  46. package/src/components/command-bar/command-registry.ts +261 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +129 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +625 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  70. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  71. package/src/components/pane-settings-dialog.tsx +468 -0
  72. package/src/components/pane-template-wizard.test.tsx +52 -0
  73. package/src/components/pane-template-wizard.tsx +199 -0
  74. package/src/components/price-selector-dialog.tsx +195 -0
  75. package/src/components/spinner.tsx +2 -0
  76. package/src/components/tab-bar.tsx +17 -0
  77. package/src/components/ticker-badge-text.test.tsx +160 -0
  78. package/src/components/ticker-badge-text.tsx +58 -0
  79. package/src/components/ticker-badge.tsx +88 -0
  80. package/src/components/ticker-list-table.tsx +188 -0
  81. package/src/components/toggle-list.tsx +74 -0
  82. package/src/components/ui/button.tsx +77 -0
  83. package/src/components/ui/data-table.tsx +210 -0
  84. package/src/components/ui/external-link.tsx +22 -0
  85. package/src/components/ui/fields.tsx +187 -0
  86. package/src/components/ui/frame.tsx +73 -0
  87. package/src/components/ui/index.ts +53 -0
  88. package/src/components/ui/list-view.tsx +166 -0
  89. package/src/components/ui/loading.tsx +70 -0
  90. package/src/components/ui/status.tsx +83 -0
  91. package/src/components/ui/tabs.tsx +47 -0
  92. package/src/components/ui/toggle.tsx +164 -0
  93. package/src/components/ui/ui.test.tsx +134 -0
  94. package/src/data/app-persistence.ts +25 -0
  95. package/src/data/config-store.test.ts +310 -0
  96. package/src/data/config-store.ts +469 -0
  97. package/src/data/plugin-state-store.ts +102 -0
  98. package/src/data/resource-store.ts +323 -0
  99. package/src/data/session-store.ts +52 -0
  100. package/src/data/sqlite-cache.test.ts +103 -0
  101. package/src/data/sqlite-database.ts +62 -0
  102. package/src/data/sqlite-json.ts +19 -0
  103. package/src/data/ticker-repository.ts +45 -0
  104. package/src/data/ticker-store.ts +46 -0
  105. package/src/index.tsx +67 -0
  106. package/src/market-data/coordinator.test.ts +369 -0
  107. package/src/market-data/coordinator.ts +580 -0
  108. package/src/market-data/hooks.tsx +234 -0
  109. package/src/market-data/query-store.ts +28 -0
  110. package/src/market-data/request-types.test.ts +64 -0
  111. package/src/market-data/request-types.ts +70 -0
  112. package/src/market-data/result-types.ts +56 -0
  113. package/src/market-data/selectors.ts +166 -0
  114. package/src/market-data/trace.ts +7 -0
  115. package/src/pane-settings.test.ts +71 -0
  116. package/src/pane-settings.ts +73 -0
  117. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  118. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  119. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  120. package/src/plugins/builtin/ai/index.tsx +86 -0
  121. package/src/plugins/builtin/ai/providers.ts +99 -0
  122. package/src/plugins/builtin/ai/runner.ts +82 -0
  123. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  124. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  125. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  126. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  127. package/src/plugins/builtin/ai/settings.ts +79 -0
  128. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  129. package/src/plugins/builtin/ai/utils.ts +71 -0
  130. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  131. package/src/plugins/builtin/ask-ai.tsx +9 -0
  132. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  133. package/src/plugins/builtin/chat-controller.ts +340 -0
  134. package/src/plugins/builtin/chat.test.tsx +451 -0
  135. package/src/plugins/builtin/chat.tsx +620 -0
  136. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  137. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  138. package/src/plugins/builtin/data-providers.ts +16 -0
  139. package/src/plugins/builtin/debug.tsx +351 -0
  140. package/src/plugins/builtin/help.test.tsx +110 -0
  141. package/src/plugins/builtin/help.tsx +281 -0
  142. package/src/plugins/builtin/layout-manager.tsx +264 -0
  143. package/src/plugins/builtin/manual-entry.tsx +25 -0
  144. package/src/plugins/builtin/news.tsx +143 -0
  145. package/src/plugins/builtin/notes-files.ts +58 -0
  146. package/src/plugins/builtin/notes.tsx +396 -0
  147. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  148. package/src/plugins/builtin/options-availability.ts +158 -0
  149. package/src/plugins/builtin/options.tsx +364 -0
  150. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  151. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  152. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  153. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  154. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  155. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  156. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  157. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  158. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  159. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  160. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  161. package/src/plugins/builtin/sec.tsx +250 -0
  162. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  163. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  164. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  165. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  166. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  167. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  168. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  169. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  170. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  171. package/src/plugins/builtin/yahoo.ts +18 -0
  172. package/src/plugins/event-bus.ts +37 -0
  173. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  174. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  175. package/src/plugins/ibkr/account-cache.ts +137 -0
  176. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  177. package/src/plugins/ibkr/config.test.ts +68 -0
  178. package/src/plugins/ibkr/config.ts +262 -0
  179. package/src/plugins/ibkr/dialogs.tsx +83 -0
  180. package/src/plugins/ibkr/flex.test.ts +107 -0
  181. package/src/plugins/ibkr/flex.ts +267 -0
  182. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  183. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  184. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  185. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  186. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  187. package/src/plugins/ibkr/index.test.tsx +183 -0
  188. package/src/plugins/ibkr/index.tsx +153 -0
  189. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  190. package/src/plugins/ibkr/instance-selection.ts +36 -0
  191. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  192. package/src/plugins/ibkr/price-normalization.ts +59 -0
  193. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  194. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  195. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  196. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  197. package/src/plugins/ibkr/trade-utils.ts +149 -0
  198. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  199. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  200. package/src/plugins/ibkr/trading-state.ts +339 -0
  201. package/src/plugins/loader.ts +71 -0
  202. package/src/plugins/pane-manager.test.ts +103 -0
  203. package/src/plugins/pane-manager.ts +974 -0
  204. package/src/plugins/plugin-persistence.ts +85 -0
  205. package/src/plugins/plugin-runtime.test.tsx +185 -0
  206. package/src/plugins/plugin-runtime.tsx +175 -0
  207. package/src/plugins/prediction-markets/cache.ts +114 -0
  208. package/src/plugins/prediction-markets/categories.ts +223 -0
  209. package/src/plugins/prediction-markets/chart.tsx +512 -0
  210. package/src/plugins/prediction-markets/columns.ts +133 -0
  211. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  212. package/src/plugins/prediction-markets/controller.ts +725 -0
  213. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  214. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  215. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  216. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  217. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  218. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  219. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  220. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  221. package/src/plugins/prediction-markets/index.tsx +102 -0
  222. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  223. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  224. package/src/plugins/prediction-markets/launch.ts +240 -0
  225. package/src/plugins/prediction-markets/metrics.ts +311 -0
  226. package/src/plugins/prediction-markets/navigation.ts +90 -0
  227. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  228. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  229. package/src/plugins/prediction-markets/pane.tsx +294 -0
  230. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  231. package/src/plugins/prediction-markets/rows.ts +229 -0
  232. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  233. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  234. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  235. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  236. package/src/plugins/prediction-markets/settings.ts +164 -0
  237. package/src/plugins/prediction-markets/status.ts +24 -0
  238. package/src/plugins/prediction-markets/table.tsx +84 -0
  239. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  240. package/src/plugins/prediction-markets/types.ts +186 -0
  241. package/src/plugins/registry.ts +633 -0
  242. package/src/sources/exchange-rates.ts +18 -0
  243. package/src/sources/gloomberb-cloud.test.ts +199 -0
  244. package/src/sources/gloomberb-cloud.ts +326 -0
  245. package/src/sources/provider-errors.ts +19 -0
  246. package/src/sources/provider-router.test.ts +1476 -0
  247. package/src/sources/provider-router.ts +1405 -0
  248. package/src/sources/sec-edgar.test.ts +286 -0
  249. package/src/sources/sec-edgar.ts +526 -0
  250. package/src/sources/yahoo-finance.test.ts +14 -0
  251. package/src/sources/yahoo-finance.ts +1017 -0
  252. package/src/state/app-activity.test.ts +41 -0
  253. package/src/state/app-activity.ts +106 -0
  254. package/src/state/app-bootstrap.test.ts +471 -0
  255. package/src/state/app-bootstrap.ts +308 -0
  256. package/src/state/app-context.test.ts +406 -0
  257. package/src/state/app-context.test.tsx +132 -0
  258. package/src/state/app-context.tsx +969 -0
  259. package/src/state/selectors.ts +67 -0
  260. package/src/state/session-persistence.test.ts +128 -0
  261. package/src/state/session-persistence.ts +161 -0
  262. package/src/state/ticker-refresh-queue.test.ts +46 -0
  263. package/src/state/ticker-refresh-queue.ts +40 -0
  264. package/src/state/use-inline-tickers.ts +174 -0
  265. package/src/state/use-quote-streaming.test.ts +37 -0
  266. package/src/state/use-quote-streaming.test.tsx +70 -0
  267. package/src/state/use-quote-streaming.ts +80 -0
  268. package/src/state/use-session-snapshot.ts +84 -0
  269. package/src/test-support/data-provider.ts +22 -0
  270. package/src/theme/color-utils.ts +67 -0
  271. package/src/theme/colors.ts +128 -0
  272. package/src/theme/themes.test.ts +45 -0
  273. package/src/theme/themes.ts +491 -0
  274. package/src/types/broker.ts +105 -0
  275. package/src/types/config.ts +434 -0
  276. package/src/types/data-provider.ts +81 -0
  277. package/src/types/financials.ts +142 -0
  278. package/src/types/instrument.ts +29 -0
  279. package/src/types/persistence.ts +21 -0
  280. package/src/types/plugin.ts +354 -0
  281. package/src/types/ticker.ts +58 -0
  282. package/src/types/trading.ts +85 -0
  283. package/src/updater.test.ts +162 -0
  284. package/src/updater.ts +247 -0
  285. package/src/utils/api-client.ts +666 -0
  286. package/src/utils/ascii-chart.ts +172 -0
  287. package/src/utils/broker-instances.ts +47 -0
  288. package/src/utils/cli-output.ts +83 -0
  289. package/src/utils/currency-units.test.ts +49 -0
  290. package/src/utils/currency-units.ts +82 -0
  291. package/src/utils/debug-log.ts +133 -0
  292. package/src/utils/exchange-rate-map.ts +8 -0
  293. package/src/utils/format.ts +158 -0
  294. package/src/utils/fuzzy-search.ts +34 -0
  295. package/src/utils/market-status.ts +95 -0
  296. package/src/utils/options.ts +86 -0
  297. package/src/utils/price-history.ts +39 -0
  298. package/src/utils/quote-time.test.ts +22 -0
  299. package/src/utils/quote-time.ts +50 -0
  300. package/src/utils/sec.test.ts +55 -0
  301. package/src/utils/sec.ts +45 -0
  302. package/src/utils/ticker-list.test.ts +22 -0
  303. package/src/utils/ticker-list.ts +35 -0
  304. package/src/utils/ticker-metadata.ts +51 -0
  305. package/src/utils/ticker-search.test.ts +227 -0
  306. package/src/utils/ticker-search.ts +764 -0
  307. package/src/utils/ticker-tokenizer.test.ts +48 -0
  308. package/src/utils/ticker-tokenizer.ts +72 -0
  309. package/src/version.ts +1 -0
  310. package/install.mjs +0 -68
@@ -0,0 +1,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,338 @@
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { TextAttributes } from "@opentui/core";
4
+ import type { InputRenderable } 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 { TickerBadgeText } from "../../../components/ticker-badge-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 runRef = useRef<ReturnType<typeof runAiPrompt> | null>(null);
84
+ const availableProviders = getAvailableProviders(providers);
85
+ const currentProvider = providers.find((provider) => provider.id === providerId && provider.available)
86
+ ?? providers.find((provider) => provider.id === defaultProviderId)
87
+ ?? providers[0];
88
+
89
+ const tickerSymbol = ticker?.metadata.ticker ?? null;
90
+ const conversationKey = tickerSymbol && currentProvider
91
+ ? `conversation:${currentProvider.id}:${tickerSymbol}`
92
+ : "__conversation:none__";
93
+ const [persistedConversation, setPersistedConversation] = usePluginState<PersistedConversation | null>(
94
+ conversationKey,
95
+ null,
96
+ { schemaVersion: 1 },
97
+ );
98
+
99
+ useEffect(() => {
100
+ if (!tickerSymbol) {
101
+ setMessages((previous) => (previous.length === 0 ? previous : []));
102
+ return;
103
+ }
104
+
105
+ if (persistedConversation && Date.now() - persistedConversation.updatedAt <= ASK_AI_RETENTION_MS) {
106
+ chatHistories.set(tickerSymbol, persistedConversation.messages);
107
+ }
108
+
109
+ const nextMessages = chatHistories.get(tickerSymbol) ?? [];
110
+ setMessages((previous) => (
111
+ previous.length === nextMessages.length && previous.every((message, index) => message === nextMessages[index])
112
+ ? previous
113
+ : nextMessages
114
+ ));
115
+ }, [persistedConversation, tickerSymbol]);
116
+
117
+ useEffect(() => {
118
+ if (!tickerSymbol || !currentProvider || messages.length === 0) return;
119
+ const trimmed = messages.filter((message) => !message.loading).slice(-ASK_AI_HISTORY_LIMIT);
120
+ chatHistories.set(tickerSymbol, trimmed);
121
+ setPersistedConversation({
122
+ updatedAt: Date.now(),
123
+ messages: trimmed,
124
+ });
125
+ }, [currentProvider, messages, setPersistedConversation, tickerSymbol]);
126
+
127
+ const cycleProvider = useCallback(() => {
128
+ if (availableProviders.length <= 1) return;
129
+ setProviderId((current) => {
130
+ const currentIndex = providers.findIndex((provider) => provider.id === current);
131
+ let nextIndex = currentIndex >= 0 ? (currentIndex + 1) % providers.length : 0;
132
+ while (!providers[nextIndex]?.available) {
133
+ nextIndex = (nextIndex + 1) % providers.length;
134
+ }
135
+ return providers[nextIndex]?.id ?? current;
136
+ });
137
+ }, [availableProviders.length, providers, setProviderId]);
138
+
139
+ const focusInput = useCallback(() => {
140
+ setInputFocused(true);
141
+ onCapture(true);
142
+ inputRef.current?.focus();
143
+ }, [onCapture]);
144
+
145
+ const blurInput = useCallback(() => {
146
+ setInputFocused(false);
147
+ onCapture(false);
148
+ }, [onCapture]);
149
+
150
+ const sendMessage = useCallback(async (text: string) => {
151
+ if (!ticker || !currentProvider?.available || runRef.current) return;
152
+
153
+ const userMessage: ChatMessage = { role: "user", content: text };
154
+ const assistantMessage: ChatMessage = { role: "assistant", content: "", loading: true };
155
+
156
+ setMessages((previous) => [...previous, userMessage, assistantMessage]);
157
+
158
+ const context = buildTickerAiContext(
159
+ ticker,
160
+ financials,
161
+ state.config.baseCurrency,
162
+ effectiveExchangeRates,
163
+ );
164
+ 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}`;
165
+
166
+ try {
167
+ const run = runAiPrompt({
168
+ provider: currentProvider,
169
+ prompt: fullPrompt,
170
+ onChunk: (output) => {
171
+ setMessages((previous) => {
172
+ const updated = [...previous];
173
+ updated[updated.length - 1] = { role: "assistant", content: output, loading: true };
174
+ return updated;
175
+ });
176
+ },
177
+ });
178
+ runRef.current = run;
179
+ const output = await run.done;
180
+ setMessages((previous) => {
181
+ const updated = [...previous];
182
+ updated[updated.length - 1] = {
183
+ role: "assistant",
184
+ content: output,
185
+ loading: false,
186
+ };
187
+ return updated;
188
+ });
189
+ } catch (error: any) {
190
+ setMessages((previous) => {
191
+ const updated = [...previous];
192
+ updated[updated.length - 1] = {
193
+ role: "assistant",
194
+ content: `Error: ${error?.message || "Failed to run AI command"}`,
195
+ loading: false,
196
+ };
197
+ return updated;
198
+ });
199
+ } finally {
200
+ runRef.current = null;
201
+ }
202
+ }, [currentProvider, effectiveExchangeRates, financials, state.config.baseCurrency, ticker]);
203
+
204
+ useKeyboard((event) => {
205
+ if (!focused) return;
206
+
207
+ const isEnter = event.name === "enter" || event.name === "return";
208
+ if (!inputFocused) {
209
+ if (isEnter) {
210
+ focusInput();
211
+ return;
212
+ }
213
+ if (event.name === "t" || event.name === "T") {
214
+ cycleProvider();
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (event.name === "escape") {
220
+ blurInput();
221
+ }
222
+ });
223
+
224
+ useEffect(() => () => {
225
+ runRef.current?.cancel();
226
+ }, []);
227
+
228
+ const { catalog, openTicker } = useInlineTickers(messages.map((message) => message.content));
229
+
230
+ if (!ticker) return <text fg={colors.textDim}>Select a ticker to ask AI.</text>;
231
+
232
+ if (availableProviders.length === 0) {
233
+ return (
234
+ <box flexDirection="column" padding={1} flexGrow={1}>
235
+ <box height={1}>
236
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Ask AI</text>
237
+ </box>
238
+ <box height={1} />
239
+ <text fg={colors.textDim}>No AI CLI tools detected. Install one of:</text>
240
+ <box height={1} />
241
+ <text fg={colors.text}> claude - Claude Code (claude.ai/claude-code)</text>
242
+ <text fg={colors.text}> gemini - Gemini CLI (github.com/google-gemini/gemini-cli)</text>
243
+ <text fg={colors.text}> codex - OpenAI Codex (github.com/openai/codex)</text>
244
+ </box>
245
+ );
246
+ }
247
+
248
+ const contentWidth = Math.max(width - 2, 0);
249
+ const dividerWidth = Math.max(contentWidth, 0);
250
+ const chatHeight = Math.max(height - 7, 4);
251
+ const providerHeader = getProviderHeaderParts(
252
+ currentProvider?.name || "None",
253
+ availableProviders.length > 1,
254
+ Math.max(contentWidth - "Ask AI".length - 1, 0),
255
+ );
256
+
257
+ return (
258
+ <box flexDirection="column" paddingX={1} paddingTop={1} height={height - 2}>
259
+ <box flexDirection="row" height={1}>
260
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Ask AI</text>
261
+ <box flexGrow={1} />
262
+ <box onMouseDown={cycleProvider}>
263
+ <text fg={colors.textMuted}>
264
+ {providerHeader.prefix}
265
+ <span fg={colors.textBright}>{providerHeader.label}</span>
266
+ </text>
267
+ </box>
268
+ </box>
269
+
270
+ <scrollbox height={chatHeight} scrollY>
271
+ <box flexDirection="column">
272
+ {messages.length === 0 ? (
273
+ <box paddingTop={1}>
274
+ <text fg={colors.textDim}>
275
+ Ask questions about {ticker.metadata.ticker}. Financial data will be included as context.
276
+ </text>
277
+ </box>
278
+ ) : (
279
+ messages.map((message, index) => (
280
+ <box key={index} flexDirection="column" paddingTop={index > 0 ? 1 : 0}>
281
+ <box height={1}>
282
+ <text
283
+ attributes={TextAttributes.BOLD}
284
+ fg={message.role === "user" ? colors.textBright : colors.positive}
285
+ >
286
+ {message.role === "user" ? "You" : currentProvider?.name || "AI"}
287
+ {message.loading ? " (thinking...)" : ""}
288
+ </text>
289
+ </box>
290
+ <box>
291
+ {message.content ? (
292
+ <TickerBadgeText
293
+ text={message.content}
294
+ lineWidth={contentWidth}
295
+ catalog={catalog}
296
+ textColor={colors.text}
297
+ openTicker={openTicker}
298
+ />
299
+ ) : message.loading ? (
300
+ <Spinner label="Generating..." />
301
+ ) : (
302
+ <text fg={colors.text}>{""}</text>
303
+ )}
304
+ </box>
305
+ </box>
306
+ ))
307
+ )}
308
+ </box>
309
+ </scrollbox>
310
+
311
+ <box height={1}>
312
+ <text fg={colors.textDim}>{"\u2500".repeat(dividerWidth)}</text>
313
+ </box>
314
+
315
+ <box flexDirection="row" height={1} onMouseDown={focusInput}>
316
+ <text fg={colors.textMuted}>{"> "}</text>
317
+ <box flexGrow={1}>
318
+ <input
319
+ ref={inputRef}
320
+ placeholder={inputFocused ? "Ask a question..." : "Enter to start typing"}
321
+ focused={inputFocused && focused}
322
+ textColor={colors.text}
323
+ placeholderColor={colors.textDim}
324
+ backgroundColor={inputFocused && focused ? colors.panel : colors.bg}
325
+ onInput={(value) => setInputValue(value)}
326
+ onChange={(value) => setInputValue(value)}
327
+ onSubmit={() => {
328
+ if (!inputValue.trim()) return;
329
+ void sendMessage(inputValue.trim());
330
+ setInputValue("");
331
+ (inputRef.current as any)?.editBuffer?.setText?.("");
332
+ }}
333
+ />
334
+ </box>
335
+ </box>
336
+ </box>
337
+ );
338
+ }
@@ -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
+ });