gloomberb 0.6.0 → 0.7.0

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 (156) hide show
  1. package/package.json +2 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +62 -77
  14. package/src/components/chart/native/kitty.test.ts +23 -0
  15. package/src/components/chart/native/surface-visibility.ts +48 -0
  16. package/src/components/chart/static-chart-surface.tsx +28 -4
  17. package/src/components/chart/stock-chart.test.ts +0 -16
  18. package/src/components/chart/stock-chart.tsx +52 -71
  19. package/src/components/chart/time-axis-label.tsx +43 -0
  20. package/src/components/command-bar/command-bar.test.tsx +71 -8
  21. package/src/components/command-bar/command-bar.tsx +388 -713
  22. package/src/components/command-bar/confirm-body.tsx +51 -0
  23. package/src/components/command-bar/list-model.ts +77 -0
  24. package/src/components/command-bar/view-model.ts +0 -19
  25. package/src/components/command-bar/workflow-ops.ts +4 -0
  26. package/src/components/data-table-stack-view.test.tsx +39 -4
  27. package/src/components/data-table-stack-view.tsx +11 -104
  28. package/src/components/data-table-view.tsx +43 -79
  29. package/src/components/feed-data-table-stack-view.tsx +10 -17
  30. package/src/components/feed-stack-controller.ts +39 -0
  31. package/src/components/index.ts +6 -2
  32. package/src/components/layout/floating-pane.tsx +30 -2
  33. package/src/components/layout/header.tsx +10 -7
  34. package/src/components/layout/pane-content.tsx +2 -0
  35. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  36. package/src/components/layout/pane-cycle-order.ts +32 -0
  37. package/src/components/layout/pane-footer.tsx +26 -4
  38. package/src/components/layout/pane-header.tsx +5 -5
  39. package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
  40. package/src/components/layout/shell.test.tsx +66 -22
  41. package/src/components/layout/shell.tsx +13 -6
  42. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  43. package/src/components/pane-settings-dialog.tsx +12 -4
  44. package/src/components/pane-template-wizard.test.tsx +3 -4
  45. package/src/components/speedometer-gauge.test.tsx +45 -0
  46. package/src/components/speedometer-gauge.tsx +558 -0
  47. package/src/components/table-view-shared.tsx +158 -0
  48. package/src/components/ticker-list-table-view.tsx +42 -72
  49. package/src/components/ui/confirm-dialog.tsx +66 -0
  50. package/src/components/ui/data-table.tsx +18 -10
  51. package/src/components/ui/index.ts +0 -1
  52. package/src/components/ui/page-stack-view.tsx +39 -10
  53. package/src/components/ui/remote-image.tsx +15 -40
  54. package/src/components/ui/table-layout.test.ts +16 -0
  55. package/src/components/ui/table-layout.ts +10 -3
  56. package/src/components/ui/ui.test.tsx +100 -20
  57. package/src/core/state/app-state.ts +6 -3
  58. package/src/market-data/coordinator.test.ts +1 -1
  59. package/src/market-data/coordinator.ts +0 -1
  60. package/src/news/aggregator.ts +0 -3
  61. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  62. package/src/plugins/builtin/ai/providers.ts +0 -17
  63. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  64. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  65. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  66. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  67. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  68. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  69. package/src/plugins/builtin/chat-controller.ts +75 -6
  70. package/src/plugins/builtin/chat.test.tsx +6 -6
  71. package/src/plugins/builtin/chat.tsx +29 -20
  72. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  73. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  74. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  75. package/src/plugins/builtin/correlation/index.tsx +1 -6
  76. package/src/plugins/builtin/debug.tsx +1 -3
  77. package/src/plugins/builtin/econ/index.tsx +1 -2
  78. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  79. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  80. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  82. package/src/plugins/builtin/help.test.tsx +2 -2
  83. package/src/plugins/builtin/help.tsx +2 -2
  84. package/src/plugins/builtin/holders/index.tsx +10 -15
  85. package/src/plugins/builtin/insider/index.tsx +22 -89
  86. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  87. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  88. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  89. package/src/plugins/builtin/news.tsx +4 -16
  90. package/src/plugins/builtin/notes.tsx +18 -55
  91. package/src/plugins/builtin/options.test.tsx +58 -2
  92. package/src/plugins/builtin/options.tsx +108 -49
  93. package/src/plugins/builtin/plugin-groups.ts +2 -0
  94. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  95. package/src/plugins/builtin/research/index.tsx +5 -21
  96. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  97. package/src/plugins/builtin/sec.tsx +13 -66
  98. package/src/plugins/builtin/sectors/index.tsx +1 -1
  99. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  100. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  102. package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
  103. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +69 -4
  104. package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
  105. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  106. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  107. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  108. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  109. package/src/plugins/ibkr/gateway-service.ts +1 -1
  110. package/src/plugins/ibkr/index.test.tsx +0 -2
  111. package/src/plugins/ibkr/trade-tab.test.tsx +56 -5
  112. package/src/plugins/ibkr/trade-tab.tsx +13 -11
  113. package/src/plugins/pane-manager.ts +0 -13
  114. package/src/plugins/plugin-runtime.test.tsx +6 -6
  115. package/src/plugins/plugin-runtime.tsx +13 -8
  116. package/src/plugins/prediction-markets/cli.ts +0 -1
  117. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  118. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  119. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  120. package/src/plugins/registry.ts +10 -39
  121. package/src/react/input.ts +11 -2
  122. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  123. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  124. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  125. package/src/renderers/electrobun/view/input-host.tsx +123 -12
  126. package/src/renderers/electrobun/view/key-event.ts +1 -1
  127. package/src/renderers/electrobun/view/styles.css +331 -0
  128. package/src/renderers/electrobun/view/ui-host.tsx +22 -3
  129. package/src/renderers/opentui/chart-surface.tsx +179 -0
  130. package/src/renderers/opentui/host.tsx +6 -0
  131. package/src/renderers/opentui/image-loader.test.ts +25 -0
  132. package/src/renderers/opentui/image-loader.ts +105 -0
  133. package/src/renderers/opentui/image-surface.tsx +266 -0
  134. package/src/renderers/opentui/input-host.tsx +45 -6
  135. package/src/renderers/opentui/start.tsx +48 -30
  136. package/src/renderers/opentui/test-utils.tsx +17 -0
  137. package/src/renderers/opentui/ui-host.tsx +7 -3
  138. package/src/sources/provider-router.ts +0 -4
  139. package/src/state/app-context.test.ts +45 -0
  140. package/src/state/app-context.tsx +4 -0
  141. package/src/state/pane-scroll-registry.tsx +254 -0
  142. package/src/state/use-inline-tickers.test.tsx +73 -0
  143. package/src/state/use-inline-tickers.ts +7 -1
  144. package/src/test-support/plugin-runtime.ts +3 -2
  145. package/src/theme/colors.ts +12 -3
  146. package/src/theme/themes.test.ts +23 -1
  147. package/src/theme/themes.ts +115 -0
  148. package/src/types/broker.ts +1 -1
  149. package/src/types/data-provider.ts +0 -1
  150. package/src/types/plugin.ts +0 -2
  151. package/src/ui/host.tsx +4 -0
  152. package/src/ui/index.tsx +9 -1
  153. package/src/utils/back-navigation.ts +24 -1
  154. package/src/version.ts +1 -1
  155. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  156. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,6 +1,6 @@
1
- import { Box, Text } from "../../../ui";
1
+ import { Text } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../../types/plugin";
3
+ import type { GloomPlugin } from "../../../types/plugin";
4
4
  import type { SecFilingItem } from "../../../types/data-provider";
5
5
  import {
6
6
  useResolvedEntryValue,
@@ -14,50 +14,19 @@ import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTa
14
14
  import { usePluginPaneState } from "../../plugin-runtime";
15
15
  import { isUsEquityTicker } from "../../../utils/sec";
16
16
  import { formatCompact, formatCurrency } from "../../../utils/format";
17
- import { parseForm4Xml, transactionTypeLabel, type InsiderTransaction } from "./insider-data";
17
+ import { parseForm4Xml, type InsiderTransaction } from "./insider-data";
18
18
  import { createTickerSurfacePaneTemplate } from "../ticker-surface";
19
+ import {
20
+ buildInsiderTransactionDetailBody,
21
+ buildInsiderTransactionTitle,
22
+ formatFilingFormLabel,
23
+ formatFilingShortDate,
24
+ renderFilingNotice,
25
+ } from "../sec-filing-display";
19
26
 
20
27
  const FORM4_LIMIT = 20;
21
28
  const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
22
29
 
23
- const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
24
-
25
- function formatDate(d: Date | string | number): string {
26
- const date = d instanceof Date ? d : new Date(d);
27
- if (isNaN(date.getTime())) return "—";
28
- return `${MONTH_NAMES[date.getMonth()]} ${String(date.getDate()).padStart(2, " ")} ${date.getFullYear()}`;
29
- }
30
-
31
- function wrapLines(text: string, width: number): string[] {
32
- const words = text.trim().split(/\s+/);
33
- const lines: string[] = [];
34
- let current = "";
35
- for (const word of words) {
36
- if (!current) { current = word; continue; }
37
- if (current.length + 1 + word.length <= width) {
38
- current = `${current} ${word}`;
39
- } else {
40
- lines.push(current);
41
- current = word;
42
- }
43
- }
44
- if (current) lines.push(current);
45
- return lines;
46
- }
47
-
48
- function renderNotice(message: string, width: number) {
49
- const lines = wrapLines(message, width - 4);
50
- return (
51
- <Box flexDirection="column" paddingX={1} paddingY={1}>
52
- {lines.map((line, i) => (
53
- <Box key={i} height={1}>
54
- <Text fg={colors.textDim}>{line}</Text>
55
- </Box>
56
- ))}
57
- </Box>
58
- );
59
- }
60
-
61
30
  interface ParsedFiling {
62
31
  filing: SecFilingItem;
63
32
  transaction: InsiderTransaction | null;
@@ -99,46 +68,18 @@ function truncateText(text: string, width: number): string {
99
68
  return `${text.slice(0, width - 3)}...`;
100
69
  }
101
70
 
102
- function formatFilingForm(form: string): string {
103
- const value = form.trim();
104
- return value ? `FORM ${value}` : "FORM 4";
105
- }
106
-
107
- function buildTransactionTitle(transaction: InsiderTransaction): string {
108
- const type = transactionTypeLabel(transaction.transactionType);
109
- const price = transaction.pricePerShare != null
110
- ? ` @ ${formatCurrency(transaction.pricePerShare)}`
111
- : "";
112
- const value = transaction.totalValue != null
113
- ? ` | ${formatCurrency(transaction.totalValue)}`
114
- : "";
115
- return `${type} ${formatCompact(transaction.shares)} shares${price}${value}`;
116
- }
117
-
118
- function buildTransactionDetailBody(transaction: InsiderTransaction): string {
119
- const lines = [
120
- `Transaction: ${transactionTypeLabel(transaction.transactionType)}`,
121
- `Date: ${formatDate(transaction.filingDate)}`,
122
- `Shares: ${formatCompact(transaction.shares)}`,
123
- `Price/Share: ${transaction.pricePerShare != null ? formatCurrency(transaction.pricePerShare) : "—"}`,
124
- `Total Value: ${transaction.totalValue != null ? formatCurrency(transaction.totalValue) : "—"}`,
125
- `Shares Owned After: ${transaction.sharesOwned != null ? formatCompact(transaction.sharesOwned) : "—"}`,
126
- ];
127
- return lines.join("\n");
128
- }
129
-
130
71
  function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
131
72
  return parsed.map(({ filing, transaction, isLoading }) => {
132
73
  const filingMeta = [
133
- `Filed ${formatDate(filing.filingDate)}`,
74
+ `Filed ${formatFilingShortDate(filing.filingDate)}`,
134
75
  `Accession ${filing.accessionNumber}`,
135
- formatFilingForm(filing.form),
76
+ formatFilingFormLabel(filing.form),
136
77
  ];
137
78
 
138
79
  if (!transaction) {
139
80
  return {
140
81
  id: filing.accessionNumber,
141
- eyebrow: formatFilingForm(filing.form),
82
+ eyebrow: formatFilingFormLabel(filing.form),
142
83
  title: isLoading ? "Loading Form 4 filing..." : "Form 4 transaction unavailable",
143
84
  timestamp: filing.filingDate,
144
85
  detailTitle: isLoading ? "Loading Form 4 filing..." : "Form 4 transaction unavailable",
@@ -152,7 +93,7 @@ function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
152
93
  return {
153
94
  id: filing.accessionNumber,
154
95
  eyebrow: transaction.reportedName,
155
- title: buildTransactionTitle(transaction),
96
+ title: buildInsiderTransactionTitle(transaction),
156
97
  timestamp: transaction.filingDate,
157
98
  preview: transaction.title || undefined,
158
99
  detailTitle: transaction.reportedName,
@@ -160,12 +101,12 @@ function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
160
101
  ...(transaction.title ? [transaction.title] : []),
161
102
  ...filingMeta,
162
103
  ],
163
- detailBody: buildTransactionDetailBody(transaction),
104
+ detailBody: buildInsiderTransactionDetailBody(transaction),
164
105
  };
165
106
  });
166
107
  }
167
108
 
168
- export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "width" | "height" | "focused">) {
109
+ function InsiderView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
169
110
  const { ticker } = usePaneTicker();
170
111
  const tickerKey = ticker?.metadata.ticker ?? "none";
171
112
  const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>(`insider:selectedIdx:${tickerKey}`, 0);
@@ -290,18 +231,18 @@ export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "wi
290
231
  registrationId: "insider",
291
232
  focused,
292
233
  url: error ? null : openFiling?.filingUrl,
293
- source: openFiling?.form ? formatFilingForm(openFiling.form) : null,
234
+ source: openFiling?.form ? formatFilingFormLabel(openFiling.form) : null,
294
235
  info: footerInfo,
295
236
  hints: footerHints,
296
237
  label: "filing",
297
238
  });
298
239
 
299
240
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view insider activity.</Text>;
300
- if (!eligibleTicker) return renderNotice("Insider transactions are only shown for US equities.", width);
241
+ if (!eligibleTicker) return renderFilingNotice("Insider transactions are only shown for US equities.", width);
301
242
  if (loading && allFilings.length === 0) return <Spinner label="Loading insider filings..." />;
302
- if (error) return renderNotice(`Error: ${error}`, width);
243
+ if (error) return renderFilingNotice(`Error: ${error}`, width);
303
244
  if (!loading && form4Filings.length === 0) {
304
- return renderNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
245
+ return renderFilingNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
305
246
  }
306
247
 
307
248
  return (
@@ -321,14 +262,6 @@ export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "wi
321
262
  );
322
263
  }
323
264
 
324
- function InsiderTab(props: DetailTabProps) {
325
- return <InsiderView {...props} />;
326
- }
327
-
328
- function InsiderPane({ focused, width, height }: PaneProps) {
329
- return <InsiderView focused={focused} width={width} height={height} />;
330
- }
331
-
332
265
  export const insiderPlugin: GloomPlugin = {
333
266
  id: "insider",
334
267
  name: "Insider Trading",
@@ -340,7 +273,7 @@ export const insiderPlugin: GloomPlugin = {
340
273
  id: "insider",
341
274
  name: "Insider",
342
275
  icon: "I",
343
- component: InsiderPane,
276
+ component: InsiderView,
344
277
  defaultPosition: "right",
345
278
  defaultMode: "floating",
346
279
  defaultFloatingSize: { width: 100, height: 30 },
@@ -364,7 +297,7 @@ export const insiderPlugin: GloomPlugin = {
364
297
  id: "insider",
365
298
  name: "Insider",
366
299
  order: 47,
367
- component: InsiderTab,
300
+ component: InsiderView,
368
301
  isVisible: ({ ticker }) => isUsEquityTicker(ticker),
369
302
  });
370
303
  },
@@ -191,7 +191,7 @@ const INDEX_SHORT: Record<string, string> = {
191
191
  "^RUT": "RUT",
192
192
  };
193
193
 
194
- export function MarketMoversPane({ focused, width, height }: PaneProps) {
194
+ function MarketMoversPane({ focused, width, height }: PaneProps) {
195
195
  const dataProvider = useAssetData();
196
196
  const { pinTicker } = usePluginTickerActions();
197
197
  const [activeTab, setActiveTab] = useState<TabId>("gainers");
@@ -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);
@@ -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);
@@ -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(),