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,1161 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core";
4
+ import { useKeyboard } from "@opentui/react";
5
+ import type { PaneProps } from "../../../types/plugin";
6
+ import type { ColumnConfig } from "../../../types/config";
7
+ import type { InstrumentSearchResult } from "../../../types/instrument";
8
+ import type { TickerFinancials } from "../../../types/financials";
9
+ import type { TickerRecord } from "../../../types/ticker";
10
+ import { useAppState, usePaneInstance, usePaneInstanceId } from "../../../state/app-context";
11
+ import { usePluginPaneState, usePluginState } from "../../../plugins/plugin-runtime";
12
+ import { useFxRatesMap, useTickerFinancialsMap } from "../../../market-data/hooks";
13
+ import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
14
+ import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types";
15
+ import { useQuoteStreaming } from "../../../state/use-quote-streaming";
16
+ import { TickerListTable } from "../../../components/ticker-list-table";
17
+ import { Button, EmptyState, Spinner } from "../../../components";
18
+ import { colors } from "../../../theme/colors";
19
+ import { formatTimeAgo } from "../../../utils/format";
20
+ import { getColumnValue, getSortValue, type ColumnContext } from "../portfolio-list/metrics";
21
+ import { getSharedDataProvider, getSharedRegistry } from "../../registry";
22
+ import { upsertTickerFromSearchResult } from "../../../utils/ticker-search";
23
+ import { buildGloomberbCliInstructions, resolveGloomberbCliCommand } from "./gloomberb-cli";
24
+ import { detectProviders, getAiProvider, resolveDefaultAiProviderId } from "./providers";
25
+ import { runAiPrompt, isAiRunCancelled, type AiRunController } from "./runner";
26
+ import {
27
+ buildScreenerPrompt,
28
+ deriveScreenerTitle,
29
+ getScreenerPromptSignature,
30
+ mergeScreenerResults,
31
+ parseScreenerResponse,
32
+ type ValidatedScreenerResult,
33
+ } from "./screener-contract";
34
+ import { getAiScreenerPaneSettings, resolveVisibleAiScreenerColumns } from "./settings";
35
+ import { truncateWithEllipsis, wrapTextLines } from "./utils";
36
+
37
+ interface AiScreenerTab {
38
+ id: string;
39
+ title: string;
40
+ prompt: string;
41
+ providerId: string;
42
+ createdAt: number;
43
+ lastRunAt: number | null;
44
+ lastSuccessAt: number | null;
45
+ lastRunPromptSignature: string | null;
46
+ lastError: string | null;
47
+ lastWarning: string | null;
48
+ summary: string | null;
49
+ debugOutput: string | null;
50
+ results: ValidatedScreenerResult[];
51
+ }
52
+
53
+ interface PersistedAiScreenerPaneState {
54
+ tabs: AiScreenerTab[];
55
+ }
56
+
57
+ interface ScreenerSortPreference {
58
+ columnId: string | null;
59
+ direction: "asc" | "desc";
60
+ }
61
+
62
+ interface RunState {
63
+ tabId: string;
64
+ mode: "refresh" | "force";
65
+ output: string;
66
+ }
67
+
68
+ interface ScreenerEditorState {
69
+ mode: "create" | "edit";
70
+ tabId: string | null;
71
+ providerId: string;
72
+ prompt: string;
73
+ key: string;
74
+ error: string | null;
75
+ }
76
+
77
+ const EMPTY_PANE_STATE: PersistedAiScreenerPaneState = { tabs: [] };
78
+ const EMPTY_SORT: ScreenerSortPreference = { columnId: null, direction: "asc" };
79
+ const FORCE_CONFIRM_TIMEOUT_MS = 4000;
80
+ const DETAIL_FOOTER_LINES = 3;
81
+ let nextScreenerTabId = 1;
82
+ let nextScreenerEditorId = 1;
83
+
84
+ function generateScreenerTabId(): string {
85
+ return `${Date.now()}-${nextScreenerTabId++}`;
86
+ }
87
+
88
+ function generateScreenerEditorKey(): string {
89
+ return `editor-${Date.now()}-${nextScreenerEditorId++}`;
90
+ }
91
+
92
+ function createScreenerTab(prompt: string, providerId: string): AiScreenerTab {
93
+ return {
94
+ id: generateScreenerTabId(),
95
+ title: deriveScreenerTitle(prompt),
96
+ prompt,
97
+ providerId,
98
+ createdAt: Date.now(),
99
+ lastRunAt: null,
100
+ lastSuccessAt: null,
101
+ lastRunPromptSignature: null,
102
+ lastError: null,
103
+ lastWarning: null,
104
+ summary: null,
105
+ debugOutput: null,
106
+ results: [],
107
+ };
108
+ }
109
+
110
+ function ScreenerPromptEditor({
111
+ editorKey,
112
+ initialValue,
113
+ focused,
114
+ textareaRef,
115
+ }: {
116
+ editorKey: string;
117
+ initialValue: string;
118
+ focused: boolean;
119
+ textareaRef: RefObject<TextareaRenderable | null>;
120
+ }) {
121
+ useEffect(() => {
122
+ if (focused) {
123
+ textareaRef.current?.focus?.();
124
+ }
125
+ }, [editorKey, focused]);
126
+
127
+ return (
128
+ <box
129
+ flexGrow={1}
130
+ minHeight={10}
131
+ border
132
+ borderColor={colors.border}
133
+ backgroundColor={colors.panel}
134
+ >
135
+ <textarea
136
+ key={editorKey}
137
+ ref={textareaRef}
138
+ initialValue={initialValue}
139
+ placeholder="Examples: humanoid robot suppliers, defense software compounders, EM payment rails, obesity-drug picks-and-shovels..."
140
+ focused={focused}
141
+ textColor={colors.text}
142
+ placeholderColor={colors.textDim}
143
+ backgroundColor={colors.panel}
144
+ flexGrow={1}
145
+ />
146
+ </box>
147
+ );
148
+ }
149
+
150
+ function normalizeTabs(value: unknown): AiScreenerTab[] {
151
+ if (!Array.isArray((value as PersistedAiScreenerPaneState | undefined)?.tabs)) return [];
152
+ return (value as PersistedAiScreenerPaneState).tabs
153
+ .filter((entry): entry is AiScreenerTab => !!entry && typeof entry === "object" && typeof (entry as AiScreenerTab).id === "string")
154
+ .map((entry) => ({
155
+ ...entry,
156
+ title: typeof entry.title === "string" ? entry.title : "New Screener",
157
+ prompt: typeof entry.prompt === "string" ? entry.prompt : "",
158
+ providerId: typeof entry.providerId === "string" ? entry.providerId : "claude",
159
+ createdAt: typeof entry.createdAt === "number" ? entry.createdAt : Date.now(),
160
+ lastRunAt: typeof entry.lastRunAt === "number" ? entry.lastRunAt : null,
161
+ lastSuccessAt: typeof entry.lastSuccessAt === "number" ? entry.lastSuccessAt : null,
162
+ lastRunPromptSignature: typeof entry.lastRunPromptSignature === "string" ? entry.lastRunPromptSignature : null,
163
+ lastError: typeof entry.lastError === "string" ? entry.lastError : null,
164
+ lastWarning: typeof entry.lastWarning === "string" ? entry.lastWarning : null,
165
+ summary: typeof entry.summary === "string" ? entry.summary : null,
166
+ debugOutput: typeof entry.debugOutput === "string" ? entry.debugOutput : null,
167
+ results: Array.isArray(entry.results)
168
+ ? entry.results.filter((result): result is ValidatedScreenerResult =>
169
+ !!result
170
+ && typeof result === "object"
171
+ && typeof result.symbol === "string"
172
+ && typeof result.exchange === "string"
173
+ && typeof result.reason === "string"
174
+ && typeof result.resolvedName === "string",
175
+ )
176
+ : [],
177
+ }));
178
+ }
179
+
180
+ function getResultMap(tab: AiScreenerTab | null): Map<string, ValidatedScreenerResult> {
181
+ return new Map((tab?.results ?? []).map((result) => [result.symbol, result]));
182
+ }
183
+
184
+ function summarizeWarning(unresolved: string[], duplicateCount: number): string | null {
185
+ const parts: string[] = [];
186
+ if (duplicateCount > 0) {
187
+ parts.push(`Dropped ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"}.`);
188
+ }
189
+ if (unresolved.length > 0) {
190
+ parts.push(`Could not resolve ${unresolved.length}: ${unresolved.slice(0, 5).join(", ")}${unresolved.length > 5 ? "..." : ""}.`);
191
+ }
192
+ return parts.length > 0 ? parts.join(" ") : null;
193
+ }
194
+
195
+ function resolveResultSymbol(result: InstrumentSearchResult): string {
196
+ return (result.brokerContract?.localSymbol || result.symbol.split(".")[0] || "").trim().toUpperCase();
197
+ }
198
+
199
+ function matchesExchange(result: InstrumentSearchResult, exchange: string): boolean {
200
+ if (!exchange) return true;
201
+ const normalized = exchange.toUpperCase();
202
+ return result.exchange.toUpperCase() === normalized
203
+ || (result.primaryExchange?.toUpperCase() ?? "") === normalized
204
+ || (result.brokerContract?.exchange?.toUpperCase() ?? "") === normalized
205
+ || (result.brokerContract?.primaryExchange?.toUpperCase() ?? "") === normalized;
206
+ }
207
+
208
+ async function resolveCandidateTicker(
209
+ candidate: { symbol: string; exchange: string; reason: string },
210
+ localTickers: ReadonlyMap<string, TickerRecord>,
211
+ stateDispatch: ReturnType<typeof useAppState>["dispatch"],
212
+ ): Promise<ValidatedScreenerResult | null> {
213
+ const registry = getSharedRegistry();
214
+ const dataProvider = getSharedDataProvider() ?? registry?.dataProvider ?? null;
215
+ if (!registry || !dataProvider) {
216
+ throw new Error("AI screener could not access the ticker repository.");
217
+ }
218
+
219
+ const localTicker = localTickers.get(candidate.symbol);
220
+ if (localTicker && (!candidate.exchange || localTicker.metadata.exchange.toUpperCase() === candidate.exchange)) {
221
+ return {
222
+ symbol: localTicker.metadata.ticker,
223
+ exchange: localTicker.metadata.exchange,
224
+ reason: candidate.reason,
225
+ resolvedName: localTicker.metadata.name,
226
+ };
227
+ }
228
+
229
+ const searchResults = await dataProvider.search(candidate.symbol);
230
+ const matches = searchResults.filter((result) => resolveResultSymbol(result) === candidate.symbol);
231
+ const selected = matches.find((result) => matchesExchange(result, candidate.exchange))
232
+ ?? matches[0]
233
+ ?? null;
234
+ if (!selected) return null;
235
+
236
+ const { ticker, created } = await upsertTickerFromSearchResult(registry.tickerRepository, selected);
237
+ stateDispatch({ type: "UPDATE_TICKER", ticker });
238
+ if (created) {
239
+ registry.events.emit("ticker:added", {
240
+ symbol: ticker.metadata.ticker,
241
+ ticker,
242
+ });
243
+ }
244
+
245
+ return {
246
+ symbol: ticker.metadata.ticker,
247
+ exchange: ticker.metadata.exchange,
248
+ reason: candidate.reason,
249
+ resolvedName: ticker.metadata.name,
250
+ };
251
+ }
252
+
253
+ async function validateScreenerResults(
254
+ candidates: Array<{ symbol: string; exchange: string; reason: string }>,
255
+ localTickers: ReadonlyMap<string, TickerRecord>,
256
+ stateDispatch: ReturnType<typeof useAppState>["dispatch"],
257
+ ): Promise<{ results: ValidatedScreenerResult[]; warning: string | null }> {
258
+ const resolved: ValidatedScreenerResult[] = [];
259
+ const unresolved: string[] = [];
260
+ let duplicateCount = 0;
261
+ const seen = new Set<string>();
262
+
263
+ for (const candidate of candidates) {
264
+ const result = await resolveCandidateTicker(candidate, localTickers, stateDispatch);
265
+ if (!result) {
266
+ unresolved.push(candidate.symbol);
267
+ continue;
268
+ }
269
+ if (seen.has(result.symbol)) {
270
+ duplicateCount += 1;
271
+ continue;
272
+ }
273
+ seen.add(result.symbol);
274
+ resolved.push(result);
275
+ }
276
+
277
+ return {
278
+ results: resolved,
279
+ warning: summarizeWarning(unresolved, duplicateCount),
280
+ };
281
+ }
282
+
283
+ function sortScreenerRows(
284
+ rows: TickerRecord[],
285
+ resultMap: Map<string, ValidatedScreenerResult>,
286
+ financialsMap: Map<string, TickerFinancials>,
287
+ sortPreference: ScreenerSortPreference,
288
+ columnContext: ColumnContext,
289
+ columns: ColumnConfig[],
290
+ ): TickerRecord[] {
291
+ if (!sortPreference.columnId) return rows;
292
+
293
+ const column = columns.find((entry) => entry.id === sortPreference.columnId);
294
+ if (!column) return rows;
295
+
296
+ return [...rows].sort((left, right) => {
297
+ const leftValue = column.id === "reason"
298
+ ? (resultMap.get(left.metadata.ticker)?.reason ?? "")
299
+ : getSortValue(column, left, financialsMap.get(left.metadata.ticker), columnContext);
300
+ const rightValue = column.id === "reason"
301
+ ? (resultMap.get(right.metadata.ticker)?.reason ?? "")
302
+ : getSortValue(column, right, financialsMap.get(right.metadata.ticker), columnContext);
303
+
304
+ if (leftValue == null && rightValue == null) return 0;
305
+ if (leftValue == null) return 1;
306
+ if (rightValue == null) return -1;
307
+
308
+ const comparison = typeof leftValue === "string" && typeof rightValue === "string"
309
+ ? leftValue.localeCompare(rightValue)
310
+ : (leftValue as number) - (rightValue as number);
311
+ return sortPreference.direction === "asc" ? comparison : -comparison;
312
+ });
313
+ }
314
+
315
+ function ActionChip({
316
+ label,
317
+ onPress,
318
+ active = false,
319
+ disabled = false,
320
+ }: {
321
+ label: string;
322
+ onPress?: () => void;
323
+ active?: boolean;
324
+ disabled?: boolean;
325
+ }) {
326
+ return (
327
+ <box
328
+ backgroundColor={active ? colors.selected : colors.panel}
329
+ onMouseDown={!disabled && onPress ? (event: any) => {
330
+ event.stopPropagation?.();
331
+ event.preventDefault?.();
332
+ onPress();
333
+ } : undefined}
334
+ >
335
+ <text fg={disabled ? colors.textDim : active ? colors.selectedText : colors.text}>
336
+ {` ${label} `}
337
+ </text>
338
+ </box>
339
+ );
340
+ }
341
+
342
+ export function AiScreenerPane({ focused, width, height }: PaneProps) {
343
+ const paneId = usePaneInstanceId();
344
+ const paneInstance = usePaneInstance();
345
+ const { state, dispatch } = useAppState();
346
+ const [providers] = useState(() => detectProviders());
347
+ const availableProviders = providers.filter((provider) => provider.available);
348
+ const selectableProviders = availableProviders.length > 0 ? availableProviders : providers;
349
+ const [persistedState, setPersistedState] = usePluginState<PersistedAiScreenerPaneState>(
350
+ `screener-pane:${paneId}`,
351
+ EMPTY_PANE_STATE,
352
+ { schemaVersion: 1 },
353
+ );
354
+ const [activeTabId, setActiveTabId] = usePluginPaneState<string | null>("activeTabId", null);
355
+ const [cursorSymbol, setCursorSymbol] = usePluginPaneState<string | null>("cursorSymbol", null);
356
+ const [sorts, setSorts] = usePluginPaneState<Record<string, ScreenerSortPreference>>("sorts", {});
357
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
358
+ const [runState, setRunState] = useState<RunState | null>(null);
359
+ const [now, setNow] = useState(Date.now());
360
+ const [editorState, setEditorState] = useState<ScreenerEditorState | null>(null);
361
+ const [forceConfirmTabId, setForceConfirmTabId] = useState<string | null>(null);
362
+ const runRef = useRef<AiRunController | null>(null);
363
+ const editorTextareaRef = useRef<TextareaRenderable | null>(null);
364
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
365
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
366
+ const initializedRef = useRef(false);
367
+ const pendingInitialRunRef = useRef<string | null>(null);
368
+ const lastTabClickRef = useRef<{ tabId: string; at: number } | null>(null);
369
+
370
+ useEffect(() => {
371
+ const timer = setInterval(() => setNow(Date.now()), 60_000);
372
+ return () => clearInterval(timer);
373
+ }, []);
374
+
375
+ const tabs = useMemo(() => normalizeTabs(persistedState), [persistedState]);
376
+ const paneSettings = useMemo(
377
+ () => getAiScreenerPaneSettings(paneInstance?.settings),
378
+ [paneInstance?.settings],
379
+ );
380
+ const columns = useMemo(
381
+ () => resolveVisibleAiScreenerColumns(paneSettings.columnIds),
382
+ [paneSettings.columnIds],
383
+ );
384
+
385
+ useEffect(() => {
386
+ if (initializedRef.current) return;
387
+ initializedRef.current = true;
388
+ if (tabs.length > 0) return;
389
+
390
+ const seedPrompt = typeof paneInstance?.params?.prompt === "string"
391
+ ? paneInstance.params.prompt.trim()
392
+ : "";
393
+ if (!seedPrompt) return;
394
+
395
+ const seedProviderId = typeof paneInstance?.params?.providerId === "string"
396
+ ? paneInstance.params.providerId
397
+ : resolveDefaultAiProviderId(providers);
398
+ const seededTab = createScreenerTab(seedPrompt, seedProviderId);
399
+ pendingInitialRunRef.current = seededTab.id;
400
+ setPersistedState({ tabs: [seededTab] });
401
+ setActiveTabId(seededTab.id);
402
+ }, [paneInstance?.params?.prompt, paneInstance?.params?.providerId, providers, setActiveTabId, setPersistedState, tabs.length]);
403
+
404
+ useEffect(() => {
405
+ if (tabs.length === 0) {
406
+ if (activeTabId !== null) setActiveTabId(null);
407
+ if (cursorSymbol !== null) setCursorSymbol(null);
408
+ return;
409
+ }
410
+ if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
411
+ setActiveTabId(tabs[0]!.id);
412
+ }
413
+ }, [activeTabId, cursorSymbol, setActiveTabId, setCursorSymbol, tabs]);
414
+
415
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null;
416
+ const resultMap = useMemo(() => getResultMap(activeTab), [activeTab]);
417
+ const activeSort = activeTab ? sorts[activeTab.id] ?? EMPTY_SORT : EMPTY_SORT;
418
+ const activePromptSignature = activeTab ? getScreenerPromptSignature(activeTab.prompt, activeTab.providerId) : null;
419
+ const promptDirty = activeTab ? activePromptSignature !== activeTab.lastRunPromptSignature : false;
420
+
421
+ const screenerTickers = useMemo(() => (
422
+ (activeTab?.results ?? [])
423
+ .map((result) => state.tickers.get(result.symbol) ?? null)
424
+ .filter((ticker): ticker is TickerRecord => ticker != null)
425
+ ), [activeTab?.results, state.tickers]);
426
+ const financialsMap = useTickerFinancialsMap(screenerTickers);
427
+
428
+ const trackedCurrencies = useMemo(() => [
429
+ state.config.baseCurrency,
430
+ ...screenerTickers.map((ticker) => ticker.metadata.currency),
431
+ ...screenerTickers.map((ticker) => financialsMap.get(ticker.metadata.ticker)?.quote?.currency ?? null),
432
+ ], [financialsMap, screenerTickers, state.config.baseCurrency]);
433
+ const exchangeRates = useFxRatesMap(trackedCurrencies);
434
+ const effectiveExchangeRates = exchangeRates.size > 1 || state.exchangeRates.size === 0
435
+ ? exchangeRates
436
+ : state.exchangeRates;
437
+ const columnContext: ColumnContext = useMemo(() => ({
438
+ baseCurrency: state.config.baseCurrency,
439
+ exchangeRates: effectiveExchangeRates,
440
+ now,
441
+ }), [effectiveExchangeRates, now, state.config.baseCurrency]);
442
+
443
+ const sortedTickers = useMemo(
444
+ () => sortScreenerRows(screenerTickers, resultMap, financialsMap, activeSort, columnContext, columns),
445
+ [activeSort, columnContext, columns, financialsMap, resultMap, screenerTickers],
446
+ );
447
+ const quoteTargets = useMemo(() => (
448
+ sortedTickers
449
+ .map((ticker) => quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker))
450
+ .filter((target): target is NonNullable<ReturnType<typeof quoteSubscriptionTargetFromTicker>> => target != null)
451
+ ), [sortedTickers]);
452
+
453
+ useQuoteStreaming(quoteTargets);
454
+
455
+ useEffect(() => {
456
+ const coordinator = getSharedMarketDataCoordinator();
457
+ if (!coordinator) return;
458
+ for (const ticker of sortedTickers) {
459
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
460
+ if (instrument) {
461
+ void coordinator.loadSnapshot(instrument);
462
+ }
463
+ }
464
+ }, [sortedTickers]);
465
+
466
+ useEffect(() => {
467
+ if (sortedTickers.length === 0) {
468
+ if (cursorSymbol !== null) setCursorSymbol(null);
469
+ return;
470
+ }
471
+ if (!cursorSymbol || !sortedTickers.some((ticker) => ticker.metadata.ticker === cursorSymbol)) {
472
+ setCursorSymbol(sortedTickers[0]!.metadata.ticker);
473
+ }
474
+ }, [cursorSymbol, setCursorSymbol, sortedTickers]);
475
+
476
+ const selectedResult = activeTab?.results.find((result) => result.symbol === cursorSymbol)
477
+ ?? activeTab?.results[0]
478
+ ?? null;
479
+ const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
480
+ const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
481
+ const isRunningActiveTab = runState?.tabId === activeTab?.id;
482
+ const forceRunArmed = !!activeTab && forceConfirmTabId === activeTab.id;
483
+
484
+ const syncHeaderScroll = useCallback(() => {
485
+ const header = headerScrollRef.current;
486
+ const body = scrollRef.current;
487
+ if (header && body && header.scrollLeft !== body.scrollLeft) {
488
+ header.scrollLeft = body.scrollLeft;
489
+ }
490
+ }, []);
491
+
492
+ const handleBodyScrollActivity = useCallback(() => {
493
+ syncHeaderScroll();
494
+ }, [syncHeaderScroll]);
495
+
496
+ const updateTabs = useCallback((updater: (tabs: AiScreenerTab[]) => AiScreenerTab[]) => {
497
+ setPersistedState((current) => ({
498
+ tabs: updater(normalizeTabs(current)),
499
+ }));
500
+ }, [setPersistedState]);
501
+
502
+ const upsertTab = useCallback((tabId: string, updater: (tab: AiScreenerTab) => AiScreenerTab) => {
503
+ updateTabs((current) => current.map((tab) => (tab.id === tabId ? updater(tab) : tab)));
504
+ }, [updateTabs]);
505
+
506
+ const openCreateEditor = useCallback(() => {
507
+ setForceConfirmTabId(null);
508
+ setEditorState({
509
+ mode: "create",
510
+ tabId: null,
511
+ providerId: resolveDefaultAiProviderId(providers),
512
+ prompt: "",
513
+ key: generateScreenerEditorKey(),
514
+ error: null,
515
+ });
516
+ }, [providers]);
517
+
518
+ const openEditEditor = useCallback((tab: AiScreenerTab | null) => {
519
+ if (!tab) return;
520
+ setForceConfirmTabId(null);
521
+ setEditorState({
522
+ mode: "edit",
523
+ tabId: tab.id,
524
+ providerId: tab.providerId,
525
+ prompt: tab.prompt,
526
+ key: generateScreenerEditorKey(),
527
+ error: null,
528
+ });
529
+ }, []);
530
+
531
+ const closeEditor = useCallback(() => {
532
+ editorTextareaRef.current = null;
533
+ setEditorState(null);
534
+ }, []);
535
+
536
+ const cycleEditorProvider = useCallback((direction: -1 | 1) => {
537
+ setEditorState((current) => {
538
+ if (!current || selectableProviders.length === 0) return current;
539
+ const currentIndex = Math.max(0, selectableProviders.findIndex((provider) => provider.id === current.providerId));
540
+ const nextIndex = (currentIndex + direction + selectableProviders.length) % selectableProviders.length;
541
+ return {
542
+ ...current,
543
+ providerId: selectableProviders[nextIndex]!.id,
544
+ error: null,
545
+ };
546
+ });
547
+ }, [selectableProviders]);
548
+
549
+ const saveEditor = useCallback(() => {
550
+ if (!editorState) return;
551
+ const prompt = editorTextareaRef.current?.editBuffer.getText().trim() || editorState.prompt.trim();
552
+ if (!prompt) {
553
+ setEditorState((current) => current ? { ...current, error: "Prompt is required." } : current);
554
+ return;
555
+ }
556
+
557
+ if (editorState.mode === "create") {
558
+ const tab = createScreenerTab(prompt, editorState.providerId);
559
+ pendingInitialRunRef.current = tab.id;
560
+ updateTabs((current) => [...current, tab]);
561
+ setActiveTabId(tab.id);
562
+ setCursorSymbol(null);
563
+ } else if (editorState.tabId) {
564
+ upsertTab(editorState.tabId, (current) => ({
565
+ ...current,
566
+ prompt,
567
+ providerId: editorState.providerId,
568
+ lastError: null,
569
+ }));
570
+ }
571
+
572
+ editorTextareaRef.current = null;
573
+ setEditorState(null);
574
+ }, [editorState, setActiveTabId, setCursorSymbol, updateTabs, upsertTab]);
575
+
576
+ const addTab = useCallback(() => {
577
+ openCreateEditor();
578
+ }, [openCreateEditor]);
579
+
580
+ const removeTab = useCallback((tabId: string) => {
581
+ if (runState?.tabId === tabId) {
582
+ runRef.current?.cancel();
583
+ runRef.current = null;
584
+ setRunState(null);
585
+ }
586
+ if (forceConfirmTabId === tabId) {
587
+ setForceConfirmTabId(null);
588
+ }
589
+ setEditorState((current) => current?.tabId === tabId ? null : current);
590
+ updateTabs((current) => current.filter((tab) => tab.id !== tabId));
591
+ if (activeTabId === tabId) {
592
+ const index = tabs.findIndex((tab) => tab.id === tabId);
593
+ const fallback = tabs.filter((tab) => tab.id !== tabId)[Math.max(0, index - 1)] ?? null;
594
+ setActiveTabId(fallback?.id ?? null);
595
+ setCursorSymbol(null);
596
+ }
597
+ }, [activeTabId, forceConfirmTabId, runState?.tabId, setActiveTabId, setCursorSymbol, tabs, updateTabs]);
598
+
599
+ const editTab = useCallback((tab: AiScreenerTab | null) => {
600
+ openEditEditor(tab);
601
+ }, [openEditEditor]);
602
+
603
+ const editActiveTab = useCallback(() => {
604
+ editTab(activeTab);
605
+ }, [activeTab, editTab]);
606
+
607
+ const cancelRun = useCallback(() => {
608
+ runRef.current?.cancel();
609
+ runRef.current = null;
610
+ setRunState(null);
611
+ }, []);
612
+
613
+ const runTab = useCallback(async (tabId: string, mode: "refresh" | "force") => {
614
+ const tab = normalizeTabs({ tabs }).find((entry) => entry.id === tabId);
615
+ if (!tab) return;
616
+
617
+ const provider = getAiProvider(tab.providerId, providers);
618
+ if (!provider) {
619
+ upsertTab(tab.id, (current) => ({ ...current, lastError: "Unknown AI provider configured for this screener." }));
620
+ return;
621
+ }
622
+ if (!provider.available) {
623
+ upsertTab(tab.id, (current) => ({
624
+ ...current,
625
+ lastError: `${provider.name} is not installed or not available in PATH.`,
626
+ }));
627
+ return;
628
+ }
629
+
630
+ runRef.current?.cancel();
631
+ setForceConfirmTabId(null);
632
+ const samePrompt = tab.lastRunPromptSignature === getScreenerPromptSignature(tab.prompt, tab.providerId);
633
+ const includePreviousResults = samePrompt && tab.results.length > 0;
634
+ const startedAt = Date.now();
635
+ const cliInstructions = buildGloomberbCliInstructions(resolveGloomberbCliCommand());
636
+ const prompt = buildScreenerPrompt({
637
+ currentDate: new Date(startedAt).toISOString().slice(0, 10),
638
+ prompt: tab.prompt,
639
+ provider,
640
+ cliInstructions,
641
+ previousResults: tab.results,
642
+ includePreviousResults,
643
+ });
644
+
645
+ setRunState({ tabId: tab.id, mode, output: "" });
646
+ upsertTab(tab.id, (current) => ({
647
+ ...current,
648
+ lastRunAt: startedAt,
649
+ lastError: null,
650
+ lastWarning: null,
651
+ }));
652
+
653
+ let rawOutput = "";
654
+ try {
655
+ const run = runAiPrompt({
656
+ provider,
657
+ prompt,
658
+ onChunk: (output) => {
659
+ setRunState((current) => (
660
+ current?.tabId === tab.id
661
+ ? { ...current, output }
662
+ : current
663
+ ));
664
+ },
665
+ });
666
+ runRef.current = run;
667
+ rawOutput = await run.done;
668
+
669
+ const parsed = parseScreenerResponse(rawOutput);
670
+ const validated = await validateScreenerResults(parsed.tickers, state.tickers, dispatch);
671
+ const nextResults = !samePrompt || mode === "force"
672
+ ? validated.results
673
+ : mergeScreenerResults(tab.results, validated.results);
674
+
675
+ upsertTab(tab.id, (current) => ({
676
+ ...current,
677
+ title: parsed.title || current.title || deriveScreenerTitle(current.prompt),
678
+ summary: parsed.summary,
679
+ results: nextResults,
680
+ lastSuccessAt: startedAt,
681
+ lastRunPromptSignature: getScreenerPromptSignature(current.prompt, current.providerId),
682
+ lastError: null,
683
+ lastWarning: validated.warning,
684
+ debugOutput: null,
685
+ }));
686
+ } catch (error: unknown) {
687
+ if (isAiRunCancelled(error)) return;
688
+ upsertTab(tab.id, (current) => ({
689
+ ...current,
690
+ lastError: error instanceof Error ? error.message : "AI screener failed.",
691
+ debugOutput: rawOutput.trim() || current.debugOutput,
692
+ }));
693
+ } finally {
694
+ if (runRef.current) {
695
+ runRef.current = null;
696
+ }
697
+ setRunState((current) => (current?.tabId === tab.id ? null : current));
698
+ }
699
+ }, [dispatch, providers, setForceConfirmTabId, state.tickers, tabs, upsertTab]);
700
+
701
+ useEffect(() => {
702
+ if (!pendingInitialRunRef.current) return;
703
+ if (!tabs.some((tab) => tab.id === pendingInitialRunRef.current)) return;
704
+ const targetTabId = pendingInitialRunRef.current;
705
+ pendingInitialRunRef.current = null;
706
+ void runTab(targetTabId, "refresh");
707
+ }, [runTab, tabs]);
708
+
709
+ useEffect(() => {
710
+ if (!forceConfirmTabId) return;
711
+ const timeoutId = setTimeout(() => {
712
+ setForceConfirmTabId((current) => (current === forceConfirmTabId ? null : current));
713
+ }, FORCE_CONFIRM_TIMEOUT_MS);
714
+ return () => clearTimeout(timeoutId);
715
+ }, [forceConfirmTabId]);
716
+
717
+ useEffect(() => {
718
+ if (forceConfirmTabId && activeTab && forceConfirmTabId !== activeTab.id) {
719
+ setForceConfirmTabId(null);
720
+ }
721
+ }, [activeTab, forceConfirmTabId]);
722
+
723
+ useEffect(() => {
724
+ if (headerScrollRef.current) {
725
+ headerScrollRef.current.horizontalScrollBar.visible = false;
726
+ }
727
+ syncHeaderScroll();
728
+ }, [columns, syncHeaderScroll]);
729
+
730
+ useEffect(() => {
731
+ const scrollBox = scrollRef.current;
732
+ if (!scrollBox) return;
733
+ const viewportHeight = scrollBox.viewport.height;
734
+ if (safeSelectedIdx < scrollBox.scrollTop) {
735
+ scrollBox.scrollTo(safeSelectedIdx);
736
+ } else if (safeSelectedIdx >= scrollBox.scrollTop + viewportHeight) {
737
+ scrollBox.scrollTo(Math.max(0, safeSelectedIdx - viewportHeight + 1));
738
+ }
739
+ }, [safeSelectedIdx]);
740
+
741
+ useEffect(() => {
742
+ const scrollBox = scrollRef.current;
743
+ if (!scrollBox) return;
744
+ scrollBox.verticalScrollBar.visible = sortedTickers.length > scrollBox.viewport.height;
745
+ }, [sortedTickers.length, width, height]);
746
+
747
+ const cycleTabs = useCallback((direction: -1 | 1) => {
748
+ if (!activeTab || tabs.length <= 1) return;
749
+ const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.id);
750
+ if (currentIndex < 0) return;
751
+ const nextIndex = (currentIndex + direction + tabs.length) % tabs.length;
752
+ setActiveTabId(tabs[nextIndex]!.id);
753
+ setCursorSymbol(null);
754
+ }, [activeTab, setActiveTabId, setCursorSymbol, tabs]);
755
+
756
+ const handleHeaderClick = useCallback((columnId: string) => {
757
+ if (!activeTab) return;
758
+ const current = sorts[activeTab.id] ?? EMPTY_SORT;
759
+ const next = current.columnId === columnId
760
+ ? (current.direction === "asc"
761
+ ? { columnId, direction: "desc" as const }
762
+ : EMPTY_SORT)
763
+ : { columnId, direction: "asc" as const };
764
+ setSorts({
765
+ ...sorts,
766
+ [activeTab.id]: next,
767
+ });
768
+ }, [activeTab, setSorts, sorts]);
769
+
770
+ useKeyboard((event) => {
771
+ if (!focused) return;
772
+
773
+ if (editorState) {
774
+ if (event.name === "escape") {
775
+ event.stopPropagation?.();
776
+ event.preventDefault?.();
777
+ closeEditor();
778
+ return;
779
+ }
780
+ if (event.ctrl && event.name === "s") {
781
+ event.stopPropagation?.();
782
+ event.preventDefault?.();
783
+ saveEditor();
784
+ return;
785
+ }
786
+ if (event.ctrl && event.name === "p") {
787
+ event.stopPropagation?.();
788
+ event.preventDefault?.();
789
+ cycleEditorProvider(event.shift ? -1 : 1);
790
+ return;
791
+ }
792
+ return;
793
+ }
794
+
795
+ const key = event.name;
796
+ const isEnter = key === "enter" || key === "return";
797
+
798
+ if (key === "j" || key === "down") {
799
+ const nextTicker = sortedTickers[Math.min(safeSelectedIdx + 1, sortedTickers.length - 1)];
800
+ if (nextTicker) {
801
+ setCursorSymbol(nextTicker.metadata.ticker);
802
+ }
803
+ return;
804
+ }
805
+ if (key === "k" || key === "up") {
806
+ const nextTicker = sortedTickers[Math.max(safeSelectedIdx - 1, 0)];
807
+ if (nextTicker) {
808
+ setCursorSymbol(nextTicker.metadata.ticker);
809
+ }
810
+ return;
811
+ }
812
+ if (event.name === "t") {
813
+ addTab();
814
+ return;
815
+ }
816
+ if (event.name === "w" && activeTab) {
817
+ removeTab(activeTab.id);
818
+ return;
819
+ }
820
+ if (event.name === "[" || event.name === "left") {
821
+ cycleTabs(-1);
822
+ return;
823
+ }
824
+ if (event.name === "]" || event.name === "right") {
825
+ cycleTabs(1);
826
+ return;
827
+ }
828
+ if (event.name === "escape" && isRunningActiveTab) {
829
+ cancelRun();
830
+ return;
831
+ }
832
+ if (event.shift && event.name === "r" && activeTab && !isRunningActiveTab) {
833
+ void runTab(activeTab.id, "force");
834
+ return;
835
+ }
836
+ if (event.name === "r" && activeTab && !isRunningActiveTab) {
837
+ void runTab(activeTab.id, "refresh");
838
+ return;
839
+ }
840
+ if (event.name === "e") {
841
+ editActiveTab();
842
+ return;
843
+ }
844
+
845
+ if (!isEnter || !cursorSymbol) return;
846
+ const registry = getSharedRegistry();
847
+ registry?.pinTickerFn(cursorSymbol, {
848
+ floating: !!event.shift,
849
+ paneType: "ticker-detail",
850
+ });
851
+ });
852
+
853
+ useEffect(() => () => {
854
+ runRef.current?.cancel();
855
+ }, []);
856
+
857
+ const contentHeight = Math.max(height - 8, 4);
858
+ const warningColor = colors.borderFocused;
859
+ const statusText = activeTab
860
+ ? isRunningActiveTab
861
+ ? `${runState.mode === "force" ? "Force refreshing" : "Refreshing"} with ${getAiProvider(activeTab.providerId, providers)?.name ?? "AI"}...`
862
+ : activeTab.lastSuccessAt
863
+ ? `Last ran ${formatTimeAgo(new Date(activeTab.lastSuccessAt))}`
864
+ : "Never run"
865
+ : "No screener selected";
866
+ const editorProvider = editorState ? getAiProvider(editorState.providerId, providers) : null;
867
+ const detailTextWidth = Math.max(12, width - 2);
868
+ const displayTabs: Array<{ id: string; title: string; draft?: boolean }> = editorState?.mode === "create"
869
+ ? [...tabs.map((tab) => ({ id: tab.id, title: tab.title })), { id: "__draft__", title: "New Screener", draft: true }]
870
+ : tabs.map((tab) => ({ id: tab.id, title: tab.title }));
871
+ const summaryLines = activeTab?.summary
872
+ ? wrapTextLines(activeTab.summary, detailTextWidth, 2)
873
+ : [];
874
+ const primaryRunLabel = !activeTab
875
+ ? "Refresh"
876
+ : activeTab.lastSuccessAt == null && activeTab.lastRunAt == null
877
+ ? "Run Screener"
878
+ : promptDirty
879
+ ? "Run Updated Prompt"
880
+ : "Refresh";
881
+ const detailLines = promptDirty
882
+ ? wrapTextLines("Prompt changed. Refresh to rerun this screener.", detailTextWidth, DETAIL_FOOTER_LINES)
883
+ : selectedResult
884
+ ? [
885
+ `${selectedResult.symbol}${selectedResult.resolvedName ? ` · ${selectedResult.resolvedName}` : ""}`,
886
+ ...wrapTextLines(selectedResult.reason, detailTextWidth, DETAIL_FOOTER_LINES - 1),
887
+ ]
888
+ : activeTab
889
+ ? ["No validated results yet."]
890
+ : ["Press t to create an AI screener tab."];
891
+ const paddedDetailLines = [...detailLines];
892
+ while (paddedDetailLines.length < DETAIL_FOOTER_LINES) {
893
+ paddedDetailLines.push("");
894
+ }
895
+
896
+ return (
897
+ <box flexDirection="column" width={width} height={height}>
898
+ <box flexDirection="row" height={1}>
899
+ {displayTabs.map((tab) => {
900
+ const isDraft = tab.draft === true;
901
+ const isActive = isDraft ? editorState?.mode === "create" : tab.id === activeTab?.id;
902
+ return (
903
+ <box key={tab.id} flexDirection="row">
904
+ <box
905
+ onMouseDown={() => {
906
+ if (editorState) return;
907
+ if (isDraft) return;
908
+ setActiveTabId(tab.id);
909
+ setCursorSymbol(null);
910
+ const now = Date.now();
911
+ const last = lastTabClickRef.current;
912
+ if (last?.tabId === tab.id && now - last.at <= 350) {
913
+ lastTabClickRef.current = null;
914
+ editTab(tab);
915
+ return;
916
+ }
917
+ lastTabClickRef.current = { tabId: tab.id, at: now };
918
+ }}
919
+ >
920
+ <text
921
+ fg={isActive ? colors.textBright : colors.textDim}
922
+ bg={isActive ? colors.selected : undefined}
923
+ attributes={isActive ? TextAttributes.BOLD : 0}
924
+ >
925
+ {` ${truncateWithEllipsis(tab.title, isDraft ? 20 : 18)} `}
926
+ </text>
927
+ </box>
928
+ {isActive && !isDraft ? (
929
+ <text fg={colors.textMuted} onMouseDown={() => { if (!editorState) removeTab(tab.id); }}>{`x `}</text>
930
+ ) : (
931
+ <text>{` `}</text>
932
+ )}
933
+ </box>
934
+ );
935
+ })}
936
+ <text fg={colors.textMuted} onMouseDown={() => { if (!editorState) addTab(); }}>{` + `}</text>
937
+ <box flexGrow={1} />
938
+ <text fg={colors.textMuted}>
939
+ {editorState
940
+ ? editorState.mode === "create" ? "creating new screener" : "editing prompt"
941
+ : activeTab
942
+ ? `${activeTab.results.length} tickers`
943
+ : "t new"}
944
+ </text>
945
+ </box>
946
+
947
+ <box flexDirection="row" height={1} gap={1}>
948
+ {editorState ? (
949
+ <>
950
+ <Button label="Save" variant="primary" shortcut="Ctrl+S" onPress={saveEditor} />
951
+ <Button label="Cancel" variant="ghost" shortcut="Esc" onPress={closeEditor} />
952
+ </>
953
+ ) : (
954
+ <>
955
+ {isRunningActiveTab ? (
956
+ <>
957
+ <Button label={runState?.mode === "force" ? "Force Refreshing..." : "Refreshing..."} variant="secondary" disabled />
958
+ <Button label="Stop" variant="ghost" shortcut="Esc" onPress={cancelRun} />
959
+ </>
960
+ ) : (
961
+ <>
962
+ <Button
963
+ label={primaryRunLabel}
964
+ variant="primary"
965
+ shortcut="r"
966
+ onPress={activeTab ? () => { void runTab(activeTab.id, "refresh"); } : undefined}
967
+ disabled={!activeTab}
968
+ />
969
+ <Button
970
+ label={forceRunArmed ? "Confirm Force Refresh" : "Force Refresh"}
971
+ variant={forceRunArmed ? "danger" : "ghost"}
972
+ shortcut="Shift+R"
973
+ onPress={activeTab ? () => {
974
+ if (forceRunArmed) {
975
+ setForceConfirmTabId(null);
976
+ void runTab(activeTab.id, "force");
977
+ return;
978
+ }
979
+ setForceConfirmTabId(activeTab.id);
980
+ } : undefined}
981
+ disabled={!activeTab}
982
+ />
983
+ </>
984
+ )}
985
+ <Button
986
+ label="Edit Prompt"
987
+ variant={promptDirty ? "primary" : "secondary"}
988
+ shortcut="e"
989
+ onPress={editActiveTab}
990
+ disabled={!activeTab}
991
+ />
992
+ </>
993
+ )}
994
+ <box flexGrow={1} />
995
+ <text fg={colors.textMuted}>
996
+ {editorState
997
+ ? `${editorProvider?.name ?? editorState.providerId} · Ctrl+S save · Esc cancel`
998
+ : activeTab
999
+ ? `${getAiProvider(activeTab.providerId, providers)?.name ?? activeTab.providerId} · ${forceRunArmed ? "Force refresh armed. Click again to confirm." : statusText}`
1000
+ : statusText}
1001
+ </text>
1002
+ </box>
1003
+
1004
+ {availableProviders.length === 0 && (
1005
+ <box flexDirection="column" paddingX={1} paddingTop={1}>
1006
+ <text fg={colors.textDim}>No AI CLI tools detected. Install `claude`, `gemini`, or `codex` to run screeners.</text>
1007
+ </box>
1008
+ )}
1009
+
1010
+ {editorState ? (
1011
+ <>
1012
+ <box flexDirection="column" paddingX={1} paddingTop={1} gap={1}>
1013
+ <text fg={colors.textDim}>
1014
+ {editorState.mode === "create"
1015
+ ? "Describe the companies or setups you want this screener to discover."
1016
+ : "Update the screener prompt or provider. Saving does not rerun it automatically."}
1017
+ </text>
1018
+ <box flexDirection="row" gap={1} flexWrap="wrap">
1019
+ {selectableProviders.map((provider) => (
1020
+ <ActionChip
1021
+ key={provider.id}
1022
+ label={provider.available ? provider.name : `${provider.name} (missing)`}
1023
+ onPress={() => {
1024
+ setEditorState((current) => current
1025
+ ? { ...current, providerId: provider.id, error: null }
1026
+ : current);
1027
+ }}
1028
+ active={editorState.providerId === provider.id}
1029
+ />
1030
+ ))}
1031
+ </box>
1032
+ {editorState.error ? (
1033
+ <text fg={colors.negative}>{editorState.error}</text>
1034
+ ) : (
1035
+ <text fg={colors.textDim}>
1036
+ The AI will return validated ticker ideas with a short reason for each one. Ctrl+P cycles providers.
1037
+ </text>
1038
+ )}
1039
+ </box>
1040
+
1041
+ <box flexGrow={1} minHeight={contentHeight} padding={1}>
1042
+ <ScreenerPromptEditor
1043
+ editorKey={editorState.key}
1044
+ initialValue={editorState.prompt}
1045
+ focused={focused}
1046
+ textareaRef={editorTextareaRef}
1047
+ />
1048
+ </box>
1049
+
1050
+ <box height={1} paddingX={1}>
1051
+ <text fg={colors.textDim}>{"\u2500".repeat(Math.max(width - 2, 0))}</text>
1052
+ </box>
1053
+
1054
+ <box flexDirection="column" paddingX={1}>
1055
+ <text fg={colors.textDim}>
1056
+ {editorProvider?.available === false
1057
+ ? `${editorProvider.name} is not currently installed. Save and switch later.`
1058
+ : "Click a provider chip or press Ctrl+P to switch. Save to keep the draft."}
1059
+ </text>
1060
+ </box>
1061
+ </>
1062
+ ) : (
1063
+ <>
1064
+ {activeTab?.lastError && (
1065
+ <box flexDirection="column" paddingX={1} paddingTop={1}>
1066
+ {wrapTextLines(activeTab.lastError, detailTextWidth, 2).map((line, index) => (
1067
+ <box key={`error:${index}`} height={1}>
1068
+ <text fg={colors.negative}>{line || " "}</text>
1069
+ </box>
1070
+ ))}
1071
+ </box>
1072
+ )}
1073
+
1074
+ {activeTab?.lastWarning && !activeTab.lastError && (
1075
+ <box flexDirection="column" paddingX={1} paddingTop={1}>
1076
+ {wrapTextLines(activeTab.lastWarning, detailTextWidth, 2).map((line, index) => (
1077
+ <box key={`warning:${index}`} height={1}>
1078
+ <text fg={warningColor}>{line || " "}</text>
1079
+ </box>
1080
+ ))}
1081
+ </box>
1082
+ )}
1083
+
1084
+ {summaryLines.length > 0 && !activeTab.lastError && (
1085
+ <box flexDirection="column" paddingX={1} paddingTop={activeTab.lastWarning ? 0 : 1}>
1086
+ {summaryLines.map((line, index) => (
1087
+ <box key={`summary:${index}`} height={1}>
1088
+ <text fg={colors.textDim}>{line || " "}</text>
1089
+ </box>
1090
+ ))}
1091
+ </box>
1092
+ )}
1093
+
1094
+ <box flexGrow={1} minHeight={contentHeight}>
1095
+ {!activeTab ? (
1096
+ <box padding={1} flexGrow={1}>
1097
+ <EmptyState title="No AI screeners yet." hint="Press t or click + to create one." />
1098
+ </box>
1099
+ ) : isRunningActiveTab && activeTab.results.length === 0 ? (
1100
+ <box padding={1} flexGrow={1}>
1101
+ <Spinner label="Running AI screener..." />
1102
+ </box>
1103
+ ) : (
1104
+ <TickerListTable
1105
+ columns={columns}
1106
+ tickers={sortedTickers}
1107
+ cursorSymbol={cursorSymbol}
1108
+ hoveredIdx={hoveredIdx}
1109
+ setHoveredIdx={setHoveredIdx}
1110
+ setCursorSymbol={setCursorSymbol}
1111
+ resolveCell={(column, ticker, financials) => {
1112
+ if (column.id === "ticker") {
1113
+ return {
1114
+ text: ticker.metadata.ticker,
1115
+ };
1116
+ }
1117
+ if (column.id === "reason") {
1118
+ return {
1119
+ text: truncateWithEllipsis(resultMap.get(ticker.metadata.ticker)?.reason ?? "", column.width),
1120
+ };
1121
+ }
1122
+ return getColumnValue(column, ticker, financials, columnContext);
1123
+ }}
1124
+ financialsMap={financialsMap}
1125
+ headerScrollRef={headerScrollRef}
1126
+ scrollRef={scrollRef}
1127
+ syncHeaderScroll={syncHeaderScroll}
1128
+ onBodyScrollActivity={handleBodyScrollActivity}
1129
+ sortColumnId={activeSort.columnId}
1130
+ sortDirection={activeSort.direction}
1131
+ onHeaderClick={handleHeaderClick}
1132
+ onRowActivate={(ticker) => {
1133
+ getSharedRegistry()?.pinTickerFn(ticker.metadata.ticker, { floating: true, paneType: "ticker-detail" });
1134
+ }}
1135
+ emptyTitle="No matches yet."
1136
+ emptyHint={promptDirty ? "Prompt changed. Refresh to rerun." : "Press r to run this screener. Use PS to customize columns."}
1137
+ />
1138
+ )}
1139
+ </box>
1140
+
1141
+ <box height={1} paddingX={1}>
1142
+ <text fg={colors.textDim}>{"\u2500".repeat(Math.max(width - 2, 0))}</text>
1143
+ </box>
1144
+
1145
+ <box flexDirection="column" paddingX={1} minHeight={DETAIL_FOOTER_LINES}>
1146
+ {paddedDetailLines.map((line, index) => (
1147
+ <box key={`detail:${index}`} height={1}>
1148
+ <text
1149
+ fg={promptDirty ? warningColor : index === 0 && selectedResult ? colors.text : colors.textDim}
1150
+ attributes={index === 0 && selectedResult ? TextAttributes.BOLD : 0}
1151
+ >
1152
+ {line || " "}
1153
+ </text>
1154
+ </box>
1155
+ ))}
1156
+ </box>
1157
+ </>
1158
+ )}
1159
+ </box>
1160
+ );
1161
+ }