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,79 @@
1
+ import { DEFAULT_COLUMNS, type ColumnConfig } from "../../../types/config";
2
+ import type { PaneSettingsDef } from "../../../types/plugin";
3
+
4
+ export interface AiScreenerPaneSettings {
5
+ columnIds: string[];
6
+ }
7
+
8
+ export const AI_REASON_COLUMN: ColumnConfig = {
9
+ id: "reason",
10
+ label: "REASON",
11
+ width: 34,
12
+ align: "left",
13
+ };
14
+
15
+ export const AI_SCREENER_COLUMN_DEFS: ColumnConfig[] = [
16
+ ...DEFAULT_COLUMNS,
17
+ { id: "bid", label: "BID", width: 10, align: "right", format: "currency" },
18
+ { id: "ask", label: "ASK", width: 10, align: "right", format: "currency" },
19
+ { id: "spread", label: "SPREAD", width: 10, align: "right", format: "currency" },
20
+ { id: "change", label: "CHG", width: 9, align: "right", format: "currency" },
21
+ { id: "ext_hours", label: "EXT%", width: 8, align: "right", format: "percent" },
22
+ { id: "dividend_yield", label: "DIV%", width: 7, align: "right", format: "percent" },
23
+ AI_REASON_COLUMN,
24
+ ];
25
+
26
+ export const DEFAULT_AI_SCREENER_COLUMN_IDS = [
27
+ "ticker",
28
+ "price",
29
+ "change_pct",
30
+ "market_cap",
31
+ "reason",
32
+ ];
33
+
34
+ const AI_SCREENER_COLUMNS_BY_ID = new Map(AI_SCREENER_COLUMN_DEFS.map((column) => [column.id, column]));
35
+
36
+ export function getAiScreenerPaneSettings(settings: Record<string, unknown> | undefined): AiScreenerPaneSettings {
37
+ const columnIds = Array.isArray(settings?.columnIds)
38
+ ? settings.columnIds.filter((value): value is string => typeof value === "string")
39
+ : DEFAULT_AI_SCREENER_COLUMN_IDS;
40
+
41
+ return {
42
+ columnIds: columnIds.length > 0 ? columnIds : DEFAULT_AI_SCREENER_COLUMN_IDS,
43
+ };
44
+ }
45
+
46
+ export function resolveVisibleAiScreenerColumns(columnIds: string[]): ColumnConfig[] {
47
+ const resolved = columnIds
48
+ .map((columnId) => AI_SCREENER_COLUMNS_BY_ID.get(columnId))
49
+ .filter((column): column is ColumnConfig => column != null);
50
+
51
+ if (resolved.length > 0) {
52
+ return resolved;
53
+ }
54
+
55
+ return DEFAULT_AI_SCREENER_COLUMN_IDS
56
+ .map((columnId) => AI_SCREENER_COLUMNS_BY_ID.get(columnId))
57
+ .filter((column): column is ColumnConfig => column != null);
58
+ }
59
+
60
+ export function buildAiScreenerPaneSettingsDef(settings: AiScreenerPaneSettings): PaneSettingsDef {
61
+ return {
62
+ title: "AI Screener Settings",
63
+ fields: [
64
+ {
65
+ key: "columnIds",
66
+ label: "Columns",
67
+ description: "Choose which columns this screener shows and in what order.",
68
+ type: "ordered-multi-select",
69
+ options: AI_SCREENER_COLUMN_DEFS.map((column) => ({
70
+ value: column.id,
71
+ label: column.label,
72
+ description: column.id === "reason"
73
+ ? "The AI explanation for why the ticker matched the prompt."
74
+ : "Standard watchlist column.",
75
+ })),
76
+ },
77
+ ],
78
+ };
79
+ }
@@ -0,0 +1,64 @@
1
+ import { convertCurrency, formatCompact, formatCompactCurrency, formatCurrency, formatPercent } from "../../../utils/format";
2
+ import type { TickerFinancials } from "../../../types/financials";
3
+ import type { TickerRecord } from "../../../types/ticker";
4
+
5
+ export function buildTickerAiContext(
6
+ ticker: TickerRecord,
7
+ financials: TickerFinancials | null,
8
+ baseCurrency: string,
9
+ exchangeRates: Map<string, number>,
10
+ ): string {
11
+ const metadata = ticker.metadata;
12
+ const quote = financials?.quote;
13
+ const fundamentals = financials?.fundamentals;
14
+ const profile = financials?.profile;
15
+ const quoteCurrency = quote?.currency ?? metadata.currency ?? baseCurrency;
16
+ const toBase = (value: number, fromCurrency: string) =>
17
+ convertCurrency(value, fromCurrency, baseCurrency, exchangeRates);
18
+ const sector = metadata.sector ?? profile?.sector;
19
+ const industry = metadata.industry ?? profile?.industry;
20
+
21
+ const lines: string[] = [
22
+ `Company: ${metadata.name} (${metadata.ticker})`,
23
+ `Exchange: ${metadata.exchange}`,
24
+ ];
25
+
26
+ if (sector) lines.push(`Sector: ${sector}`);
27
+ if (industry) lines.push(`Industry: ${industry}`);
28
+ if (profile?.description) lines.push(`Description: ${profile.description}`);
29
+
30
+ if (quote) {
31
+ lines.push(`Current Price: ${formatCurrency(quote.price, quote.currency)} (${quote.change >= 0 ? "+" : ""}${quote.changePercent.toFixed(2)}%)`);
32
+ if (quote.marketCap) lines.push(`Market Cap: ${formatCompactCurrency(toBase(quote.marketCap, quoteCurrency), baseCurrency)}`);
33
+ if (quote.high52w && quote.low52w) lines.push(`52W Range: ${formatCurrency(quote.low52w, quote.currency)} - ${formatCurrency(quote.high52w, quote.currency)}`);
34
+ }
35
+
36
+ if (fundamentals) {
37
+ if (fundamentals.trailingPE) lines.push(`P/E (TTM): ${fundamentals.trailingPE.toFixed(1)}`);
38
+ if (fundamentals.forwardPE) lines.push(`Forward P/E: ${fundamentals.forwardPE.toFixed(1)}`);
39
+ if (fundamentals.pegRatio) lines.push(`PEG: ${fundamentals.pegRatio.toFixed(2)}`);
40
+ if (fundamentals.revenue) lines.push(`Revenue: ${formatCompact(fundamentals.revenue)}`);
41
+ if (fundamentals.netIncome) lines.push(`Net Income: ${formatCompact(fundamentals.netIncome)}`);
42
+ if (fundamentals.eps) lines.push(`EPS: ${formatCurrency(fundamentals.eps)}`);
43
+ if (fundamentals.operatingMargin != null) lines.push(`Operating Margin: ${formatPercent(fundamentals.operatingMargin)}`);
44
+ if (fundamentals.profitMargin != null) lines.push(`Profit Margin: ${formatPercent(fundamentals.profitMargin)}`);
45
+ if (fundamentals.freeCashFlow) lines.push(`Free Cash Flow: ${formatCompact(fundamentals.freeCashFlow)}`);
46
+ if (fundamentals.dividendYield != null) lines.push(`Dividend Yield: ${formatPercent(fundamentals.dividendYield)}`);
47
+ if (fundamentals.return1Y != null) lines.push(`1Y Return: ${formatPercent(fundamentals.return1Y)}`);
48
+ }
49
+
50
+ if (financials?.annualStatements && financials.annualStatements.length > 0) {
51
+ const latest = financials.annualStatements[financials.annualStatements.length - 1]!;
52
+ lines.push("");
53
+ lines.push(`Latest Annual Statement (${latest.date}):`);
54
+ if (latest.totalRevenue) lines.push(` Revenue: ${formatCompact(latest.totalRevenue)}`);
55
+ if (latest.netIncome) lines.push(` Net Income: ${formatCompact(latest.netIncome)}`);
56
+ if (latest.operatingCashFlow) lines.push(` Operating CF: ${formatCompact(latest.operatingCashFlow)}`);
57
+ if (latest.freeCashFlow) lines.push(` Free Cash Flow: ${formatCompact(latest.freeCashFlow)}`);
58
+ if (latest.totalAssets) lines.push(` Total Assets: ${formatCompact(latest.totalAssets)}`);
59
+ if (latest.totalDebt) lines.push(` Total Debt: ${formatCompact(latest.totalDebt)}`);
60
+ if (latest.totalEquity) lines.push(` Equity: ${formatCompact(latest.totalEquity)}`);
61
+ }
62
+
63
+ return lines.join("\n");
64
+ }
@@ -0,0 +1,71 @@
1
+ export function truncateWithEllipsis(text: string, width: number): string {
2
+ if (width <= 0) return "";
3
+ if (text.length <= width) return text;
4
+ if (width <= 3) return text.slice(0, width);
5
+ return `${text.slice(0, width - 3)}...`;
6
+ }
7
+
8
+ export function wrapTextLines(text: string, width: number, maxLines = Number.MAX_SAFE_INTEGER): string[] {
9
+ if (width <= 0) return [];
10
+
11
+ const paragraphs = text
12
+ .split(/\r?\n/)
13
+ .map((paragraph) => paragraph.replace(/\s+/g, " ").trim());
14
+ const lines: string[] = [];
15
+
16
+ const pushLine = (line: string) => {
17
+ if (lines.length >= maxLines) return;
18
+ lines.push(line);
19
+ };
20
+
21
+ for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex += 1) {
22
+ const paragraph = paragraphs[paragraphIndex]!;
23
+ if (!paragraph) {
24
+ pushLine("");
25
+ continue;
26
+ }
27
+
28
+ let current = "";
29
+ for (const rawWord of paragraph.split(" ")) {
30
+ let word = rawWord;
31
+ while (word.length > width) {
32
+ const available = current ? width - current.length - 1 : width;
33
+ if (available <= 0) {
34
+ pushLine(current);
35
+ current = "";
36
+ continue;
37
+ }
38
+ if (word.length <= available) break;
39
+ const piece = word.slice(0, available);
40
+ word = word.slice(available);
41
+ pushLine(current ? `${current} ${piece}` : piece);
42
+ current = "";
43
+ }
44
+
45
+ if (!current) {
46
+ current = word;
47
+ continue;
48
+ }
49
+
50
+ if ((current.length + 1 + word.length) <= width) {
51
+ current = `${current} ${word}`;
52
+ } else {
53
+ pushLine(current);
54
+ current = word;
55
+ }
56
+ }
57
+
58
+ if (current) pushLine(current);
59
+ if (paragraphIndex < (paragraphs.length - 1)) pushLine("");
60
+ if (lines.length >= maxLines) break;
61
+ }
62
+
63
+ if (lines.length > maxLines) {
64
+ lines.length = maxLines;
65
+ }
66
+ if (lines.length === maxLines && paragraphs.join(" ").length > lines.join(" ").length) {
67
+ lines[maxLines - 1] = truncateWithEllipsis(lines[maxLines - 1] ?? "", width);
68
+ }
69
+
70
+ return lines;
71
+ }
@@ -0,0 +1,258 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { AppContext, PaneInstanceProvider, createInitialState } from "../../state/app-context";
5
+ import { createDefaultConfig } from "../../types/config";
6
+ import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
7
+ import { setSharedDataProviderForTests, setSharedRegistryForTests } from "../registry";
8
+ import { AskAiTab, __resetAskAiHistoryForTests, __setAskAiHistoryForTests, __setDetectedProvidersForTests, type AiProvider } from "./ask-ai";
9
+ import type { TickerRecord } from "../../types/ticker";
10
+
11
+ const PANE_ID = "ticker-detail:main";
12
+
13
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
14
+
15
+ function makeRuntime(): PluginRuntimeAccess {
16
+ const resumeState = new Map<string, unknown>();
17
+ const listeners = new Map<string, Set<() => void>>();
18
+
19
+ return {
20
+ subscribeResumeState(pluginId, key, listener) {
21
+ const storeKey = `${pluginId}:${key}`;
22
+ if (!listeners.has(storeKey)) listeners.set(storeKey, new Set());
23
+ listeners.get(storeKey)!.add(listener);
24
+ return () => listeners.get(storeKey)?.delete(listener);
25
+ },
26
+ getResumeState(pluginId, key) {
27
+ return (resumeState.get(`${pluginId}:${key}`) as any) ?? null;
28
+ },
29
+ setResumeState(pluginId, key, value) {
30
+ const storeKey = `${pluginId}:${key}`;
31
+ resumeState.set(storeKey, value);
32
+ for (const listener of listeners.get(storeKey) ?? []) listener();
33
+ },
34
+ deleteResumeState(pluginId, key) {
35
+ const storeKey = `${pluginId}:${key}`;
36
+ resumeState.delete(storeKey);
37
+ for (const listener of listeners.get(storeKey) ?? []) listener();
38
+ },
39
+ getConfigState() {
40
+ return null;
41
+ },
42
+ async setConfigState() {},
43
+ async deleteConfigState() {},
44
+ getConfigStateKeys() {
45
+ return [];
46
+ },
47
+ };
48
+ }
49
+
50
+ function makeTicker(symbol: string, name = symbol): TickerRecord {
51
+ return {
52
+ metadata: {
53
+ ticker: symbol,
54
+ exchange: "NASDAQ",
55
+ currency: "USD",
56
+ name,
57
+ portfolios: [],
58
+ watchlists: [],
59
+ positions: [],
60
+ custom: {},
61
+ tags: [],
62
+ },
63
+ };
64
+ }
65
+
66
+ function createAskAiHarness(
67
+ width = 60,
68
+ height = 12,
69
+ onCapture: (capturing: boolean) => void = () => {},
70
+ ) {
71
+ const config = createDefaultConfig("/tmp/gloomberb-ai");
72
+ config.layout.instances = config.layout.instances.map((instance) => (
73
+ instance.instanceId === PANE_ID
74
+ ? { ...instance, binding: { kind: "fixed" as const, symbol: "AAPL" } }
75
+ : instance
76
+ ));
77
+
78
+ const state = createInitialState(config);
79
+ state.focusedPaneId = PANE_ID;
80
+ state.tickers = new Map([["AAPL", makeTicker("AAPL", "Apple Inc.")]]);
81
+ state.financials = new Map([["AAPL", {
82
+ annualStatements: [],
83
+ quarterlyStatements: [],
84
+ priceHistory: [],
85
+ quote: {
86
+ symbol: "AAPL",
87
+ price: 210,
88
+ currency: "USD",
89
+ change: 6.3,
90
+ changePercent: 3,
91
+ lastUpdated: Date.now(),
92
+ },
93
+ }]]);
94
+
95
+ return (
96
+ <AppContext value={{ state, dispatch: () => {} }}>
97
+ <PaneInstanceProvider paneId={PANE_ID}>
98
+ <PluginRenderProvider pluginId="ai" runtime={makeRuntime()}>
99
+ <AskAiTab width={width} height={height} focused onCapture={onCapture} />
100
+ </PluginRenderProvider>
101
+ </PaneInstanceProvider>
102
+ </AppContext>
103
+ );
104
+ }
105
+
106
+ async function flushFrame() {
107
+ await act(async () => {
108
+ await testSetup!.renderOnce();
109
+ await testSetup!.renderOnce();
110
+ });
111
+ }
112
+
113
+ function setProviders(providers: AiProvider[]) {
114
+ __setDetectedProvidersForTests(providers);
115
+ }
116
+
117
+ afterEach(() => {
118
+ __setDetectedProvidersForTests(null);
119
+ __resetAskAiHistoryForTests();
120
+ setSharedRegistryForTests(undefined);
121
+ setSharedDataProviderForTests(undefined);
122
+ if (testSetup) {
123
+ testSetup.renderer.destroy();
124
+ testSetup = undefined;
125
+ }
126
+ });
127
+
128
+ describe("AskAiTab", () => {
129
+ test("lists Claude, Gemini, and Codex when no AI CLIs are detected", async () => {
130
+ setProviders([]);
131
+
132
+ await act(async () => {
133
+ testSetup = await testRender(
134
+ createAskAiHarness(60, 12),
135
+ { width: 60, height: 12 },
136
+ );
137
+ });
138
+
139
+ await flushFrame();
140
+
141
+ const frame = testSetup.captureCharFrame();
142
+ expect(frame).toContain("No AI CLI tools detected.");
143
+ expect(frame).toContain("claude");
144
+ expect(frame).toContain("gemini");
145
+ expect(frame).toContain("codex");
146
+ });
147
+
148
+ test("keeps the provider header readable in a narrow pane", async () => {
149
+ setProviders([
150
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
151
+ { id: "gemini", name: "Gemini", command: "gemini", available: true, buildArgs: () => [] },
152
+ ]);
153
+
154
+ await act(async () => {
155
+ testSetup = await testRender(
156
+ createAskAiHarness(24, 12),
157
+ { width: 24, height: 12 },
158
+ );
159
+ });
160
+
161
+ await flushFrame();
162
+
163
+ const headerLine = testSetup.captureCharFrame().split("\n").find((line) => line.includes("Ask AI")) ?? "";
164
+ expect(headerLine).toContain("Ask AI");
165
+ expect(headerLine).toContain("Claude (t)");
166
+ expect(headerLine).not.toContain("t to switch");
167
+ });
168
+
169
+ test("focuses the input when the prompt row is clicked", async () => {
170
+ setProviders([
171
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
172
+ ]);
173
+
174
+ await act(async () => {
175
+ testSetup = await testRender(createAskAiHarness(60, 12), {
176
+ width: 60,
177
+ height: 12,
178
+ });
179
+ });
180
+
181
+ await flushFrame();
182
+
183
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
184
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Enter to start typing"));
185
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Enter to start typing") ?? -1;
186
+
187
+ expect(inputRow).toBeGreaterThanOrEqual(0);
188
+ expect(inputCol).toBeGreaterThanOrEqual(0);
189
+
190
+ let focusedByClick = false;
191
+ for (const row of [inputRow, inputRow + 1]) {
192
+ for (const col of [inputCol + 1, inputCol + 2]) {
193
+ await act(async () => {
194
+ await testSetup!.mockMouse.click(col, row);
195
+ await testSetup!.renderOnce();
196
+ await testSetup!.renderOnce();
197
+ });
198
+ if (testSetup.captureCharFrame().includes("Ask a question...")) {
199
+ focusedByClick = true;
200
+ break;
201
+ }
202
+ }
203
+ if (focusedByClick) break;
204
+ }
205
+
206
+ expect(focusedByClick).toBe(true);
207
+
208
+ await act(async () => {
209
+ await testSetup!.mockInput.typeText("DCF");
210
+ await testSetup!.renderOnce();
211
+ await testSetup!.renderOnce();
212
+ });
213
+
214
+ const frameAfterType = testSetup.captureCharFrame();
215
+ expect(frameAfterType).toContain("> DCF");
216
+ });
217
+
218
+ test("renders assistant ticker badges and opens a floating detail pane on click", async () => {
219
+ setProviders([
220
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
221
+ ]);
222
+
223
+ const opened: string[] = [];
224
+ setSharedRegistryForTests({
225
+ pinTickerFn(symbol: string) {
226
+ opened.push(symbol);
227
+ },
228
+ } as any);
229
+
230
+ __setAskAiHistoryForTests("AAPL", [
231
+ { role: "assistant", content: "Watch $AAPL here.", loading: false },
232
+ ]);
233
+
234
+ await act(async () => {
235
+ testSetup = await testRender(createAskAiHarness(60, 12), {
236
+ width: 60,
237
+ height: 12,
238
+ });
239
+ });
240
+
241
+ await flushFrame();
242
+
243
+ const lines = testSetup.captureCharFrame().split("\n");
244
+ const row = lines.findIndex((line) => line.includes("AAPL +3%"));
245
+ const col = lines[row]?.indexOf("AAPL +3%") ?? -1;
246
+
247
+ expect(row).toBeGreaterThanOrEqual(0);
248
+ expect(col).toBeGreaterThanOrEqual(0);
249
+
250
+ await act(async () => {
251
+ await testSetup!.mockMouse.click(col + 1, row);
252
+ await testSetup!.renderOnce();
253
+ await testSetup!.renderOnce();
254
+ });
255
+
256
+ expect(opened).toEqual(["AAPL"]);
257
+ });
258
+ });
@@ -0,0 +1,9 @@
1
+ export {
2
+ AskAiDetailTab as AskAiTab,
3
+ __resetAskAiHistoryForTests,
4
+ __setAskAiHistoryForTests,
5
+ __setDetectedProvidersForTests,
6
+ type AiProvider,
7
+ } from "./ai/ask-ai-detail-tab";
8
+
9
+ export { aiPlugin as askAiPlugin } from "./ai/index";
@@ -0,0 +1,217 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { PluginPersistence } from "../../types/plugin";
3
+ import type { PersistedResourceValue } from "../../types/persistence";
4
+ import { apiClient, type ChatMessage } from "../../utils/api-client";
5
+ import { ChatController } from "./chat-controller";
6
+
7
+ const TRANSCRIPT_KIND = "channel-transcript";
8
+ const TRANSCRIPT_KEY = "everyone";
9
+ const TRANSCRIPT_SOURCE = "server";
10
+ const originalGetSession = apiClient.getSession.bind(apiClient);
11
+
12
+ class MemoryPersistence implements PluginPersistence {
13
+ private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
14
+ private readonly resources = new Map<string, PersistedResourceValue<unknown>>();
15
+
16
+ getState<T = unknown>(key: string, options?: { schemaVersion?: number }): T | null {
17
+ const record = this.state.get(key);
18
+ if (!record) return null;
19
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) {
20
+ this.state.delete(key);
21
+ return null;
22
+ }
23
+ return record.value as T;
24
+ }
25
+
26
+ setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
27
+ this.state.set(key, { schemaVersion: options?.schemaVersion ?? 1, value });
28
+ }
29
+
30
+ deleteState(key: string): void {
31
+ this.state.delete(key);
32
+ }
33
+
34
+ getResource<T = unknown>(
35
+ kind: string,
36
+ key: string,
37
+ options?: { sourceKey?: string; schemaVersion?: number; allowExpired?: boolean },
38
+ ): PersistedResourceValue<T> | null {
39
+ const record = this.resources.get(`${kind}:${key}:${options?.sourceKey ?? ""}`);
40
+ if (!record) return null;
41
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) {
42
+ this.resources.delete(`${kind}:${key}:${options.sourceKey ?? ""}`);
43
+ return null;
44
+ }
45
+ return record as PersistedResourceValue<T>;
46
+ }
47
+
48
+ setResource<T = unknown>(
49
+ kind: string,
50
+ key: string,
51
+ value: T,
52
+ options: {
53
+ cachePolicy: { staleMs: number; expireMs: number };
54
+ sourceKey?: string;
55
+ schemaVersion?: number;
56
+ provenance?: unknown;
57
+ },
58
+ ): PersistedResourceValue<T> {
59
+ const now = Date.now();
60
+ const record: PersistedResourceValue<T> = {
61
+ value,
62
+ fetchedAt: now,
63
+ staleAt: now + options.cachePolicy.staleMs,
64
+ expiresAt: now + options.cachePolicy.expireMs,
65
+ sourceKey: options.sourceKey ?? "",
66
+ schemaVersion: options.schemaVersion ?? 1,
67
+ provenance: options.provenance,
68
+ };
69
+ this.resources.set(`${kind}:${key}:${options.sourceKey ?? ""}`, record);
70
+ return record;
71
+ }
72
+
73
+ deleteResource(kind: string, key: string, options?: { sourceKey?: string }): void {
74
+ this.resources.delete(`${kind}:${key}:${options?.sourceKey ?? ""}`);
75
+ }
76
+ }
77
+
78
+ afterEach(() => {
79
+ apiClient.setSessionToken(null);
80
+ apiClient.getSession = originalGetSession;
81
+ });
82
+
83
+ describe("ChatController", () => {
84
+ test("hydrates cached session, draft, and transcript from plugin persistence", () => {
85
+ const persistence = new MemoryPersistence();
86
+ const controller = new ChatController();
87
+ const message: ChatMessage = {
88
+ id: "m1",
89
+ channelId: "everyone",
90
+ content: "hello",
91
+ replyToId: null,
92
+ createdAt: "2026-03-28T00:00:00.000Z",
93
+ user: { id: "u1", username: "vince", displayName: "Vince" },
94
+ };
95
+
96
+ persistence.setState("session", {
97
+ sessionToken: "token-123",
98
+ user: { id: "u1", username: "vince" },
99
+ }, { schemaVersion: 1 });
100
+ persistence.setState("channel:everyone", {
101
+ draft: "cached draft",
102
+ replyToId: "m1",
103
+ lastCursor: "2026-03-28T00:00:00.000Z",
104
+ }, { schemaVersion: 1 });
105
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
106
+ messages: [message],
107
+ }, {
108
+ sourceKey: TRANSCRIPT_SOURCE,
109
+ schemaVersion: 1,
110
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
111
+ });
112
+
113
+ controller.attachPersistence(persistence);
114
+
115
+ const snapshot = controller.getSnapshot();
116
+ expect(apiClient.getSessionToken()).toBe("token-123");
117
+ expect(snapshot.user?.username).toBe("vince");
118
+ expect(snapshot.draft).toBe("cached draft");
119
+ expect(snapshot.replyToId).toBe("m1");
120
+ expect(snapshot.messages.map((entry) => entry.id)).toEqual(["m1"]);
121
+ });
122
+
123
+ test("hydrates a cached verified user into the api client for offline use", async () => {
124
+ const persistence = new MemoryPersistence();
125
+ const controller = new ChatController();
126
+
127
+ persistence.setState("session", {
128
+ sessionToken: "token-123",
129
+ user: { id: "u1", username: "vince", emailVerified: true },
130
+ }, { schemaVersion: 1 });
131
+
132
+ controller.attachPersistence(persistence);
133
+
134
+ expect(apiClient.getCurrentUser()).toMatchObject({
135
+ id: "u1",
136
+ username: "vince",
137
+ emailVerified: true,
138
+ });
139
+ await expect(apiClient.ensureVerifiedSession()).resolves.toMatchObject({
140
+ id: "u1",
141
+ username: "vince",
142
+ emailVerified: true,
143
+ });
144
+ });
145
+
146
+ test("reset clears persisted chat state and session token", () => {
147
+ const persistence = new MemoryPersistence();
148
+ const controller = new ChatController();
149
+
150
+ persistence.setState("session", {
151
+ sessionToken: "token-123",
152
+ user: { id: "u1", username: "vince" },
153
+ }, { schemaVersion: 1 });
154
+ persistence.setState("channel:everyone", {
155
+ draft: "cached draft",
156
+ replyToId: null,
157
+ lastCursor: null,
158
+ }, { schemaVersion: 1 });
159
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
160
+ messages: [],
161
+ }, {
162
+ sourceKey: TRANSCRIPT_SOURCE,
163
+ schemaVersion: 1,
164
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
165
+ });
166
+
167
+ controller.attachPersistence(persistence);
168
+ controller.reset(true);
169
+
170
+ expect(apiClient.getSessionToken()).toBeNull();
171
+ expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull();
172
+ expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { sourceKey: TRANSCRIPT_SOURCE, schemaVersion: 1 })).toBeNull();
173
+ });
174
+
175
+ test("pauses verification polling while the app is backgrounded", () => {
176
+ const controller = new ChatController();
177
+
178
+ apiClient.setSessionToken("token-123");
179
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
180
+
181
+ controller.setAppActive(false);
182
+ (controller as any).syncVerificationPolling();
183
+ expect((controller as any).verificationPollTimer).toBeNull();
184
+
185
+ controller.setAppActive(true);
186
+ expect((controller as any).verificationPollTimer).not.toBeNull();
187
+
188
+ controller.reset(true);
189
+ });
190
+
191
+ test("keeps the cached session when session refresh fails transiently", async () => {
192
+ const persistence = new MemoryPersistence();
193
+ const controller = new ChatController();
194
+
195
+ persistence.setState("session", {
196
+ sessionToken: "token-123",
197
+ user: { id: "u1", username: "vince", emailVerified: true },
198
+ }, { schemaVersion: 1 });
199
+
200
+ controller.attachPersistence(persistence);
201
+ apiClient.getSession = async () => {
202
+ throw new Error("network down");
203
+ };
204
+
205
+ await expect(controller.refreshSession()).rejects.toThrow("network down");
206
+ expect(apiClient.getSessionToken()).toBe("token-123");
207
+ expect(persistence.getState("session", { schemaVersion: 1 })).toEqual({
208
+ sessionToken: "token-123",
209
+ user: { id: "u1", username: "vince", emailVerified: true },
210
+ });
211
+ expect(controller.getSnapshot().user).toEqual({
212
+ id: "u1",
213
+ username: "vince",
214
+ emailVerified: true,
215
+ });
216
+ });
217
+ });