gloomberb 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,14 +1,54 @@
1
- import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
-
3
- const screenerClient = createThrottledFetch({
4
- requestsPerMinute: 15,
5
- maxRetries: 2,
6
- timeoutMs: 10_000,
7
- defaultHeaders: {
8
- "User-Agent": "Gloomberb/0.4.1",
9
- Accept: "application/json",
10
- },
11
- });
1
+ import { createThrottledFetch, type ThrottledFetchTransport } from "../../../utils/throttled-fetch";
2
+
3
+ const YAHOO_FINANCE_HOSTS = [
4
+ "query2.finance.yahoo.com",
5
+ "query1.finance.yahoo.com",
6
+ ] as const;
7
+
8
+ const YAHOO_FINANCE_HEADERS = {
9
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
10
+ Accept: "application/json,text/plain,*/*",
11
+ "Accept-Language": "en-US,en;q=0.9",
12
+ Referer: "https://finance.yahoo.com/",
13
+ };
14
+
15
+ export interface YahooScreenerApi {
16
+ fetchJson<T = unknown>(path: string, params: Record<string, string | number>): Promise<T>;
17
+ }
18
+
19
+ export function createYahooScreenerApi(transport?: ThrottledFetchTransport): YahooScreenerApi {
20
+ const client = createThrottledFetch({
21
+ requestsPerMinute: 15,
22
+ maxRetries: 2,
23
+ timeoutMs: 10_000,
24
+ defaultHeaders: YAHOO_FINANCE_HEADERS,
25
+ transport,
26
+ });
27
+
28
+ return {
29
+ async fetchJson<T = unknown>(path: string, params: Record<string, string | number>): Promise<T> {
30
+ let lastError: unknown;
31
+ for (const host of YAHOO_FINANCE_HOSTS) {
32
+ const url = new URL(`https://${host}${path}`);
33
+ for (const [key, value] of Object.entries(params)) {
34
+ url.searchParams.set(key, String(value));
35
+ }
36
+
37
+ try {
38
+ return await client.fetchJson<T>(url.toString());
39
+ } catch (error) {
40
+ lastError = error;
41
+ }
42
+ }
43
+
44
+ throw lastError instanceof Error
45
+ ? lastError
46
+ : new Error("Yahoo Finance screener request failed");
47
+ },
48
+ };
49
+ }
50
+
51
+ const screenerApi = createYahooScreenerApi();
12
52
 
13
53
  export type ScreenerCategory = "day_gainers" | "day_losers" | "most_actives";
14
54
 
@@ -75,9 +115,18 @@ export function parseScreenerResponse(data: any): ScreenerQuote[] {
75
115
  }
76
116
  }
77
117
 
78
- export async function fetchScreener(category: ScreenerCategory, count = 25): Promise<ScreenerQuote[]> {
79
- const url = `https://query2.finance.yahoo.com/v1/finance/screener/predefined/saved?scrIds=${category}&count=${count}`;
80
- const data = await screenerClient.fetchJson(url);
118
+ export async function fetchScreener(
119
+ category: ScreenerCategory,
120
+ count = 25,
121
+ api: YahooScreenerApi = screenerApi,
122
+ ): Promise<ScreenerQuote[]> {
123
+ const data = await api.fetchJson("/v1/finance/screener/predefined/saved", {
124
+ formatted: "false",
125
+ lang: "en-US",
126
+ region: "US",
127
+ scrIds: category,
128
+ count,
129
+ });
81
130
  return parseScreenerResponse(data);
82
131
  }
83
132
 
@@ -96,9 +145,13 @@ export function parseTrendingResponse(data: any): TrendingSymbol[] {
96
145
  }
97
146
  }
98
147
 
99
- export async function fetchTrending(count = 25): Promise<TrendingSymbol[]> {
100
- const url = `https://query2.finance.yahoo.com/v1/finance/trending/US?count=${count}`;
101
- const data = await screenerClient.fetchJson(url);
148
+ export async function fetchTrending(
149
+ count = 25,
150
+ api: YahooScreenerApi = screenerApi,
151
+ ): Promise<TrendingSymbol[]> {
152
+ const data = await api.fetchJson("/v1/finance/trending/US", {
153
+ count,
154
+ });
102
155
  return parseTrendingResponse(data);
103
156
  }
104
157
 
@@ -1,7 +1,7 @@
1
1
  import { Box } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { PaneProps } from "../../../types/plugin";
4
- import { useNewsArticles } from "../../../news/hooks";
4
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
5
5
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
6
6
  import { Spinner } from "../../../components";
7
7
  import type { MarketNewsItem } from "../../../types/news-source";
@@ -41,7 +41,8 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
41
41
  const [aiRunning, setAiRunning] = useState(false);
42
42
  const [spinFrame, setSpinFrame] = useState(0);
43
43
  const processingRef = useRef(false);
44
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
44
+ const loadNewsStory = useLoadNewsStory();
45
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
45
46
  const { readArticleIds, markArticleRead } = useNewsReadState();
46
47
 
47
48
  useEffect(() => {
@@ -1,7 +1,7 @@
1
+ import type { PaneProps } from "../../../types/plugin";
1
2
  import type { GloomPluginContext } from "../../../types/plugin";
3
+ import type { NewsQuery } from "../../../news/types";
2
4
  import { createRssNewsCapability } from "./rss-source";
3
- import { TopPane } from "./top-pane";
4
- import { FeedPane } from "./feed-pane";
5
5
  import { IndustryPane } from "./industry-pane";
6
6
  import { BreakingPane } from "./breaking-pane";
7
7
  import {
@@ -15,6 +15,45 @@ import {
15
15
  loadNewsFeedSettings,
16
16
  saveNewsFeedSettings,
17
17
  } from "./feed-config";
18
+ import { NEWS_QUERY_PRESETS } from "./news-query-presets";
19
+ import { NewsPresetPane } from "./news-preset-pane";
20
+ import type { NewsColumnId, NewsSortPreference } from "./news-table";
21
+
22
+ interface NewsPresetPaneConfig {
23
+ paneKey: string;
24
+ title: string;
25
+ query: NewsQuery;
26
+ columns: NewsColumnId[];
27
+ defaultSort: NewsSortPreference;
28
+ emptyStateTitle: string;
29
+ emptyStateHint: string;
30
+ }
31
+
32
+ function createNewsPresetPane(config: NewsPresetPaneConfig) {
33
+ return function PresetNewsPane(props: PaneProps) {
34
+ return <NewsPresetPane {...props} {...config} />;
35
+ };
36
+ }
37
+
38
+ const TopPane = createNewsPresetPane({
39
+ paneKey: "top",
40
+ title: "Top News",
41
+ query: NEWS_QUERY_PRESETS.top,
42
+ columns: ["rank", "time", "source", "title", "tickers", "importance"],
43
+ defaultSort: { columnId: "importance", direction: "desc" },
44
+ emptyStateTitle: "No top stories yet",
45
+ emptyStateHint: "Try refreshing later as new headlines are ranked.",
46
+ });
47
+
48
+ const FeedPane = createNewsPresetPane({
49
+ paneKey: "feed",
50
+ title: "News Feed",
51
+ query: NEWS_QUERY_PRESETS.feed,
52
+ columns: ["time", "source", "title", "tickers", "categories"],
53
+ defaultSort: { columnId: "time", direction: "desc" },
54
+ emptyStateTitle: "No feed stories yet",
55
+ emptyStateHint: "Try refreshing later as wire stories arrive.",
56
+ });
18
57
 
19
58
  export function registerNewsWireFeatures(ctx: GloomPluginContext): () => void {
20
59
  setDigestPersistence(ctx.persistence);
@@ -2,7 +2,7 @@ import { Box } from "../../../ui";
2
2
  import { useEffect, useMemo } from "react";
3
3
  import type { PaneProps } from "../../../types/plugin";
4
4
  import type { MarketNewsItem } from "../../../types/news-source";
5
- import { useNewsArticles } from "../../../news/hooks";
5
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
6
6
  import type { NewsQueryPhase } from "../../../news/types";
7
7
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
8
8
  import { Spinner, Tabs } from "../../../components";
@@ -43,7 +43,8 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
43
43
  const [sortPreference, setSortPreference] = usePluginPaneState<NewsSortPreference>("industry:sort", DEFAULT_SORT);
44
44
  const { articles, allArticles, phase } = useIndustryArticles(category);
45
45
  const loading = phase === "loading" || (phase === "refreshing" && articles.length === 0);
46
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
46
+ const loadNewsStory = useLoadNewsStory();
47
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
47
48
  const { readArticleIds, markArticleRead } = useNewsReadState();
48
49
  const counts = useMemo(() => {
49
50
  const next: Record<string, number> = { all: allArticles.length };
@@ -64,21 +65,6 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
64
65
  setSelectedArticleId(null);
65
66
  }, [category, setSelectedArticleId]);
66
67
 
67
- const handleRootKeyDown = (event: {
68
- name?: string;
69
- preventDefault?: () => void;
70
- stopPropagation?: () => void;
71
- }) => {
72
- if (event.name !== "left" && event.name !== "right" && event.name !== "h" && event.name !== "l") return;
73
- event.stopPropagation?.();
74
- event.preventDefault?.();
75
- const index = SECTOR_TABS.indexOf(category);
76
- const delta = event.name === "left" || event.name === "h" ? -1 : 1;
77
- const nextIndex = Math.max(0, Math.min(SECTOR_TABS.length - 1, index + delta));
78
- setCategory(SECTOR_TABS[nextIndex]!);
79
- return true;
80
- };
81
-
82
68
  useNewsArticleFooter({
83
69
  registrationId: "news-wire:industry",
84
70
  focused,
@@ -93,6 +79,7 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
93
79
  onSelect={(value) => setCategory(value as SectorNewsSelection)}
94
80
  compact
95
81
  variant="bare"
82
+ focused={focused}
96
83
  />
97
84
  </Box>
98
85
  );
@@ -126,7 +113,6 @@ export function IndustryPane({ focused, width, height }: PaneProps) {
126
113
  detailContent={detailContent}
127
114
  detailTitle={detailArticle?.title}
128
115
  rootBefore={rootBefore}
129
- onRootKeyDown={handleRootKeyDown}
130
116
  columns={["time", "source", "title", "tickers", "categories"]}
131
117
  emptyContent={loading && articles.length === 0 ? (
132
118
  <Box width="100%" paddingX={1} paddingY={1}>
@@ -10,9 +10,27 @@ import { collectNewsDisplayTickers } from "../../../news/ticker-symbols";
10
10
  import { useInlineTickers } from "../../../state/use-inline-tickers";
11
11
  import { wrapTextLines } from "../../../utils/text-wrap";
12
12
 
13
- export function useNewsArticleDetail(articles: MarketNewsItem[]) {
13
+ function hasStoryItems(article: MarketNewsItem | null): boolean {
14
+ return (article?.items?.length ?? 0) > 0;
15
+ }
16
+
17
+ function mergeLoadedArticle(base: MarketNewsItem, loaded: MarketNewsItem | null | undefined): MarketNewsItem {
18
+ if (!loaded) return base;
19
+ return {
20
+ ...base,
21
+ ...loaded,
22
+ items: hasStoryItems(loaded) ? loaded.items : base.items,
23
+ };
24
+ }
25
+
26
+ export function useNewsArticleDetail(
27
+ articles: MarketNewsItem[],
28
+ loadArticleDetail?: (articleId: string) => Promise<MarketNewsItem | null>,
29
+ ) {
14
30
  const [detailArticleId, setDetailArticleId] = useState<string | null>(null);
15
- const detailArticle = useMemo(
31
+ const [loadedArticles, setLoadedArticles] = useState<Map<string, MarketNewsItem>>(() => new Map());
32
+ const requestedArticleIds = useRef<Set<string>>(new Set());
33
+ const baseDetailArticle = useMemo(
16
34
  () => (
17
35
  detailArticleId
18
36
  ? articles.find((article) => article.id === detailArticleId) ?? null
@@ -20,12 +38,36 @@ export function useNewsArticleDetail(articles: MarketNewsItem[]) {
20
38
  ),
21
39
  [articles, detailArticleId],
22
40
  );
41
+ const detailArticle = useMemo(() => (
42
+ baseDetailArticle && detailArticleId
43
+ ? mergeLoadedArticle(baseDetailArticle, loadedArticles.get(detailArticleId))
44
+ : baseDetailArticle
45
+ ), [baseDetailArticle, detailArticleId, loadedArticles]);
23
46
 
24
47
  useEffect(() => {
25
- if (detailArticleId && !detailArticle) {
48
+ if (detailArticleId && !baseDetailArticle) {
26
49
  setDetailArticleId(null);
27
50
  }
28
- }, [detailArticle, detailArticleId]);
51
+ }, [baseDetailArticle, detailArticleId]);
52
+
53
+ useEffect(() => {
54
+ if (!detailArticleId || !baseDetailArticle || hasStoryItems(detailArticle)) return;
55
+ if (!loadArticleDetail || requestedArticleIds.current.has(detailArticleId)) return;
56
+
57
+ requestedArticleIds.current.add(detailArticleId);
58
+ void loadArticleDetail(detailArticleId)
59
+ .then((loadedArticle) => {
60
+ if (!loadedArticle) return;
61
+ setLoadedArticles((current) => {
62
+ const next = new Map(current);
63
+ next.set(detailArticleId, loadedArticle);
64
+ return next;
65
+ });
66
+ })
67
+ .catch(() => {
68
+ requestedArticleIds.current.delete(detailArticleId);
69
+ });
70
+ }, [baseDetailArticle, detailArticle, detailArticleId, loadArticleDetail]);
29
71
 
30
72
  const openArticle = useCallback((article: MarketNewsItem) => {
31
73
  setDetailArticleId(article.id);
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../ui";
2
2
  import type { NewsQuery } from "../../../news/types";
3
- import { useNewsArticles } from "../../../news/hooks";
3
+ import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
4
4
  import type { PaneProps } from "../../../types/plugin";
5
5
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
6
6
  import { Spinner } from "../../../components";
@@ -45,7 +45,8 @@ export function NewsPresetPane({
45
45
  `${paneKey}:sort`,
46
46
  defaultSort,
47
47
  );
48
- const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles);
48
+ const loadNewsStory = useLoadNewsStory();
49
+ const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
49
50
  const { readArticleIds, markArticleRead } = useNewsReadState();
50
51
 
51
52
  useNewsArticleFooter({
@@ -3,8 +3,11 @@ import { TextAttributes } from "../../../ui";
3
3
  import {
4
4
  DataTableStackView,
5
5
  TickerBadgeList,
6
+ activeStackIndex,
7
+ sortStackItems,
6
8
  type DataTableCell,
7
9
  type DataTableColumn,
10
+ type StackSortPreference,
8
11
  } from "../../../components";
9
12
  import type { MarketNewsItem } from "../../../types/news-source";
10
13
  import { colors } from "../../../theme/colors";
@@ -13,10 +16,7 @@ import { useInlineTickers } from "../../../state/use-inline-tickers";
13
16
 
14
17
  export type NewsColumnId = "rank" | "time" | "source" | "title" | "tickers" | "categories" | "importance";
15
18
 
16
- export interface NewsSortPreference {
17
- columnId: NewsColumnId;
18
- direction: "asc" | "desc";
19
- }
19
+ export type NewsSortPreference = StackSortPreference<NewsColumnId>;
20
20
 
21
21
  type NewsTableColumn = DataTableColumn & { id: NewsColumnId };
22
22
 
@@ -78,12 +78,12 @@ export function sortNewsArticles(
78
78
  articles: MarketNewsItem[],
79
79
  preference: NewsSortPreference,
80
80
  ): MarketNewsItem[] {
81
- const direction = preference.direction === "asc" ? 1 : -1;
82
- return [...articles].sort((a, b) => {
83
- const primary = compareArticle(a, b, preference.columnId) * direction;
84
- if (primary !== 0) return primary;
85
- return b.publishedAt.getTime() - a.publishedAt.getTime();
86
- });
81
+ return sortStackItems(
82
+ articles,
83
+ preference,
84
+ compareArticle,
85
+ (a, b) => b.publishedAt.getTime() - a.publishedAt.getTime(),
86
+ );
87
87
  }
88
88
 
89
89
  function nextSortPreference(current: NewsSortPreference, columnId: NewsColumnId): NewsSortPreference {
@@ -190,7 +190,7 @@ export function NewsArticleStackView({
190
190
  const { catalog: tickerCatalog, openTicker } = useInlineTickers(tableTickerTexts);
191
191
  const columns = useMemo(() => buildColumns(width, columnIds), [columnIds, width]);
192
192
  const selectedIdx = sortedArticles.findIndex((article) => article.id === selectedArticleId);
193
- const activeIdx = selectedIdx >= 0 ? selectedIdx : sortedArticles.length > 0 ? 0 : -1;
193
+ const activeIdx = activeStackIndex(sortedArticles.length, selectedIdx);
194
194
 
195
195
  const selectIndex = useCallback((index: number) => {
196
196
  setSelectedArticleId(sortedArticles[index]?.id ?? null);
@@ -1,6 +1,6 @@
1
1
  import { Text } from "../../ui";
2
2
  import { useRef, useEffect, useMemo, useState } from "react";
3
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../types/plugin";
3
+ import type { GloomPlugin } from "../../types/plugin";
4
4
  import { usePaneTicker } from "../../state/app-context";
5
5
  import { colors } from "../../theme/colors";
6
6
  import type { NewsArticle } from "../../types/news-source";
@@ -15,10 +15,6 @@ import { usePersistedNewsArticles } from "./news-wire/persisted-articles";
15
15
  import { useNewsReadState } from "./news-wire/read-state";
16
16
  import { createTickerSurfacePaneTemplate } from "./ticker-surface";
17
17
 
18
- const ARTICLE_SUMMARY_CACHE_POLICY = {
19
- staleMs: 30 * 24 * 60 * 60_000,
20
- expireMs: 90 * 24 * 60 * 60_000,
21
- };
22
18
  const NEWS_ITEM_LIMIT = 50;
23
19
  let disposeNewsWireFeatures: (() => void) | null = null;
24
20
 
@@ -56,7 +52,7 @@ function getFeedItems(
56
52
  });
57
53
  }
58
54
 
59
- export function TickerNewsView({ width, height, focused }: Pick<DetailTabProps, "width" | "height" | "focused">) {
55
+ function TickerNewsView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
60
56
  const { ticker } = usePaneTicker();
61
57
  const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
62
58
  const [selectedIdx, setSelectedIdx] = useDebouncedPluginPaneState<number>(selectionKey, 0);
@@ -151,14 +147,6 @@ export function TickerNewsView({ width, height, focused }: Pick<DetailTabProps,
151
147
  );
152
148
  }
153
149
 
154
- function NewsTab(props: DetailTabProps) {
155
- return <TickerNewsView {...props} />;
156
- }
157
-
158
- function TickerNewsPane({ focused, width, height }: PaneProps) {
159
- return <TickerNewsView focused={focused} width={width} height={height} />;
160
- }
161
-
162
150
  export const newsPlugin: GloomPlugin = {
163
151
  id: "news",
164
152
  name: "News",
@@ -171,7 +159,7 @@ export const newsPlugin: GloomPlugin = {
171
159
  id: "ticker-news",
172
160
  name: "Ticker News",
173
161
  icon: "C",
174
- component: TickerNewsPane,
162
+ component: TickerNewsView,
175
163
  defaultPosition: "right",
176
164
  defaultMode: "floating",
177
165
  defaultFloatingSize: { width: 100, height: 32 },
@@ -194,7 +182,7 @@ export const newsPlugin: GloomPlugin = {
194
182
  id: "news",
195
183
  name: "News",
196
184
  order: 40,
197
- component: NewsTab,
185
+ component: TickerNewsView,
198
186
  });
199
187
  disposeNewsWireFeatures = registerNewsWireFeatures(ctx);
200
188
  },
@@ -1,4 +1,4 @@
1
- import { Box, Input, ScrollBox, Text, TextAttributes } from "../../ui";
1
+ import { Box, Input, ScrollBox, Text } from "../../ui";
2
2
  import { useRef, useEffect, useState, useCallback } from "react";
3
3
  import { useShortcut } from "../../react/input";
4
4
  import { type InputRenderable, type TextareaRenderable } from "../../ui";
@@ -7,8 +7,8 @@ import { usePaneTicker } from "../../state/app-context";
7
7
  import { colors } from "../../theme/colors";
8
8
  import { MarkdownEditor } from "../../components/markdown-editor";
9
9
  import { MarkdownText } from "../../components/markdown-text";
10
- import { DialogFrame, Tabs, usePaneFooter } from "../../components";
11
- import { type PromptContext, useDialog, useDialogKeyboard } from "../../ui/dialog";
10
+ import { ConfirmDialog, Tabs, usePaneFooter } from "../../components";
11
+ import { type PromptContext, useDialog } from "../../ui/dialog";
12
12
  import { NotesFiles, type QuickNoteEntry } from "./notes-files";
13
13
 
14
14
  function useSyncedText(initialValue = "") {
@@ -50,6 +50,10 @@ function formatLastEdited(updatedAt: number | undefined): string {
50
50
  return `${Math.floor(elapsedDays / 365)}y ago`;
51
51
  }
52
52
 
53
+ function formatDeleteNoteTitle(title: string): string {
54
+ return title.length > 28 ? `${title.slice(0, 25)}...` : title;
55
+ }
56
+
53
57
  function MarkdownNotePreview({
54
58
  text,
55
59
  width,
@@ -82,56 +86,6 @@ function MarkdownNotePreview({
82
86
  );
83
87
  }
84
88
 
85
- function ConfirmDeleteNoteDialog({
86
- resolve,
87
- title,
88
- }: PromptContext<boolean> & {
89
- title: string;
90
- }) {
91
- const confirm = useCallback(() => resolve(true), [resolve]);
92
- const cancel = useCallback(() => resolve(false), [resolve]);
93
- const displayTitle = title.length > 28 ? `${title.slice(0, 25)}...` : title;
94
-
95
- useDialogKeyboard((event) => {
96
- event.stopPropagation();
97
- if (event.name === "enter" || event.name === "return") {
98
- confirm();
99
- return;
100
- }
101
- if (event.name === "escape") {
102
- cancel();
103
- }
104
- });
105
-
106
- return (
107
- <DialogFrame title="Delete note?" footer="Enter delete · Esc cancel">
108
- <Box flexDirection="column" width={44}>
109
- <Text fg={colors.text}>{`Delete "${displayTitle}"?`}</Text>
110
- <Box height={1} />
111
- <Text fg={colors.textDim}>This note has content.</Text>
112
- <Text fg={colors.textDim}>Deleting it cannot be undone.</Text>
113
- <Box height={1} />
114
- <Box flexDirection="row" gap={1}>
115
- <Box
116
- backgroundColor={colors.negative}
117
- onMouseDown={confirm}
118
- data-gloom-interactive="true"
119
- >
120
- <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{" Delete "}</Text>
121
- </Box>
122
- <Box
123
- backgroundColor={colors.panel}
124
- onMouseDown={cancel}
125
- data-gloom-interactive="true"
126
- >
127
- <Text fg={colors.text}>{" Cancel "}</Text>
128
- </Box>
129
- </Box>
130
- </Box>
131
- </DialogFrame>
132
- );
133
- }
134
-
135
89
  function createNotesTab(notesFiles: NotesFiles) {
136
90
  return function NotesTab({ focused, width, onCapture }: DetailTabProps) {
137
91
  const { ticker } = usePaneTicker();
@@ -383,9 +337,18 @@ function createQuickNotesPane(notesFiles: NotesFiles) {
383
337
  const confirmed = await dialog.prompt<boolean>({
384
338
  closeOnClickOutside: true,
385
339
  content: (ctx: PromptContext<boolean>) => (
386
- <ConfirmDeleteNoteDialog
340
+ <ConfirmDialog
387
341
  {...ctx}
388
- title={tab?.title ?? "Note"}
342
+ title="Delete note?"
343
+ body={[
344
+ `Delete "${formatDeleteNoteTitle(tab?.title ?? "Note")}"?`,
345
+ "This note has content.",
346
+ "Deleting it cannot be undone.",
347
+ ]}
348
+ confirmLabel="Delete"
349
+ cancelLabel="Cancel"
350
+ width={44}
351
+ footer="Enter delete · Esc cancel"
389
352
  />
390
353
  ),
391
354
  }).catch(() => false);
@@ -510,6 +473,7 @@ function createQuickNotesPane(notesFiles: NotesFiles) {
510
473
  variant="pill"
511
474
  closeMode="active"
512
475
  onAdd={addTab}
476
+ focused={focused && !editing && !renaming}
513
477
  />
514
478
  </Box>
515
479
  {/* Rename input */}
@@ -7,9 +7,28 @@ import { createTestDataProvider } from "../../test-support/data-provider";
7
7
  import { cloneLayout, createDefaultConfig } from "../../types/config";
8
8
  import type { OptionContract, OptionsChain, TickerFinancials } from "../../types/financials";
9
9
  import type { TickerRecord } from "../../types/ticker";
10
- import { OptionsTab } from "./options";
10
+ import { applyTheme, colors, hoverBg } from "../../theme/colors";
11
+ import { blendHex, contrastRatio } from "../../theme/color-utils";
12
+ import { DEFAULT_THEME, themes } from "../../theme/themes";
13
+ import { OptionsView, optionColumnColor, optionMutedColor, type OptionColumnId } from "./options";
11
14
 
12
15
  const TEST_PANE_ID = "ticker-detail:options-test";
16
+ const OPTION_TEST_COLUMN_IDS: OptionColumnId[] = [
17
+ "callOpenInterest",
18
+ "callVolume",
19
+ "callLast",
20
+ "callIv",
21
+ "callBid",
22
+ "callAsk",
23
+ "strike",
24
+ "putBid",
25
+ "putAsk",
26
+ "putIv",
27
+ "putLast",
28
+ "putVolume",
29
+ "putOpenInterest",
30
+ ];
31
+ const OPTION_TEXT_MIN_CONTRAST = 4.5;
13
32
 
14
33
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
15
34
 
@@ -98,7 +117,7 @@ function OptionsHarness({ ticker, quotePrice }: { ticker: TickerRecord; quotePri
98
117
  return (
99
118
  <AppContext value={{ state, dispatch: () => {} }}>
100
119
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
101
- <OptionsTab width={122} height={14} focused onCapture={() => {}} />
120
+ <OptionsView width={122} height={14} focused onCapture={() => {}} />
102
121
  </PaneInstanceProvider>
103
122
  </AppContext>
104
123
  );
@@ -120,9 +139,46 @@ afterEach(async () => {
120
139
  });
121
140
  testSetup = undefined;
122
141
  }
142
+ applyTheme(DEFAULT_THEME);
123
143
  setSharedMarketDataCoordinator(null);
124
144
  });
125
145
 
146
+ test("keeps option role colors readable on themed table surfaces", () => {
147
+ for (const themeId of Object.keys(themes)) {
148
+ applyTheme(themeId);
149
+ const surfaces = [
150
+ colors.bg,
151
+ colors.panel,
152
+ hoverBg(),
153
+ blendHex(colors.bg, colors.positive, 0.13),
154
+ blendHex(colors.bg, colors.negative, 0.13),
155
+ blendHex(colors.bg, colors.neutral, 0.055),
156
+ blendHex(colors.bg, colors.header, 0.1),
157
+ blendHex(colors.bg, colors.borderFocused, 0.18),
158
+ ];
159
+
160
+ for (const columnId of OPTION_TEST_COLUMN_IDS) {
161
+ for (const surface of surfaces) {
162
+ const color = optionColumnColor(columnId, surface);
163
+ const ratio = contrastRatio(color, surface);
164
+ expect(
165
+ ratio,
166
+ `${themeId} ${columnId} ${color} on ${surface}`,
167
+ ).toBeGreaterThanOrEqual(OPTION_TEXT_MIN_CONTRAST);
168
+ }
169
+ }
170
+
171
+ for (const surface of surfaces) {
172
+ const color = optionMutedColor(surface);
173
+ const ratio = contrastRatio(color, surface);
174
+ expect(
175
+ ratio,
176
+ `${themeId} muted ${color} on ${surface}`,
177
+ ).toBeGreaterThanOrEqual(OPTION_TEXT_MIN_CONTRAST);
178
+ }
179
+ }
180
+ });
181
+
126
182
  test("renders the options chain with the shared table columns", async () => {
127
183
  const provider = createTestDataProvider({
128
184
  getOptionsChain: async () => makeChain(),