gloomberb 0.7.2 → 0.7.4

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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -11,9 +11,11 @@ import {
11
11
  } from "../../../components";
12
12
  import { useShortcut } from "../../../react/input";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
14
+ import { higherContrast } from "../../../theme/color-utils";
14
15
  import type { HolderData, HolderRecord } from "../../../types/financials";
15
16
  import type { GloomPlugin } from "../../../types/plugin";
16
17
  import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
18
+ import { isPlainKey } from "../../../utils/keyboard";
17
19
  import { useAssetData, usePluginPaneState } from "../../plugin-runtime";
18
20
  import { createTickerSurfacePaneTemplate } from "../ticker-surface";
19
21
 
@@ -445,6 +447,14 @@ function desktopTileColor(row: HolderRow): string {
445
447
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
446
448
  }
447
449
 
450
+ function tileTextColor(backgroundColor: string): string {
451
+ return higherContrast(
452
+ higherContrast(colors.text, colors.textBright, backgroundColor),
453
+ colors.selectedText,
454
+ backgroundColor,
455
+ );
456
+ }
457
+
448
458
  function Tile({ tile, selected, currency, marketCap, onSelect }: {
449
459
  tile: TileLayout;
450
460
  selected: boolean;
@@ -455,7 +465,8 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
455
465
  const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
456
466
  const renderHeight = Math.max(1, tile.height - (tile.height > 2 ? 1 : 0));
457
467
  const innerWidth = Math.max(1, renderWidth - 1);
458
- const textColor = "#ffffff";
468
+ const backgroundColor = selected ? colors.selected : tileColor(tile.row);
469
+ const textColor = tileTextColor(backgroundColor);
459
470
  const attributes = selected ? TextAttributes.BOLD : TextAttributes.NONE;
460
471
  const label = tile.row.name;
461
472
  const amount = tile.row.value != null
@@ -471,7 +482,7 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
471
482
  top={tile.y}
472
483
  width={renderWidth}
473
484
  height={renderHeight}
474
- backgroundColor={selected ? colors.selected : tileColor(tile.row)}
485
+ backgroundColor={backgroundColor}
475
486
  onMouseDown={(event: PreventableMouseEvent) => {
476
487
  event.preventDefault();
477
488
  onSelect();
@@ -516,6 +527,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
516
527
  const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
517
528
  const isTiny = tile.width < 5 || tile.height < 2;
518
529
  const backgroundColor = desktopTileColor(tile.row);
530
+ const textColor = tileTextColor(backgroundColor);
519
531
  const style: CSSProperties = {
520
532
  position: "absolute",
521
533
  left: `calc(${pct(tile.x, chartWidth)} + 1px)`,
@@ -531,14 +543,14 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
531
543
  ? `1px solid ${colors.textBright}`
532
544
  : hovered
533
545
  ? `1px solid ${blendHex(colors.textBright, backgroundColor, 0.55)}`
534
- : "1px solid rgba(255,255,255,0.11)",
546
+ : `1px solid ${blendHex(backgroundColor, colors.textBright, 0.11)}`,
535
547
  boxShadow: selected
536
- ? "inset 0 0 0 1px rgba(255,255,255,0.55)"
548
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.55)}`
537
549
  : hovered
538
- ? "inset 0 0 0 1px rgba(255,255,255,0.18)"
550
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.18)}`
539
551
  : "none",
540
552
  backgroundColor,
541
- color: "#ffffff",
553
+ color: textColor,
542
554
  display: "flex",
543
555
  flexDirection: "column",
544
556
  alignItems: "stretch",
@@ -557,7 +569,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
557
569
  whiteSpace: "nowrap",
558
570
  lineHeight: 1.1,
559
571
  letterSpacing: 0,
560
- textShadow: "0 1px 1px rgba(0,0,0,0.35)",
572
+ textShadow: `0 1px 1px ${blendHex(backgroundColor, colors.bg, 0.35)}`,
561
573
  };
562
574
 
563
575
  return (
@@ -573,7 +585,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
573
585
  >
574
586
  {canShowLabel && (
575
587
  <Text
576
- fg="#ffffff"
588
+ fg={textColor}
577
589
  attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}
578
590
  style={{
579
591
  ...textStyle,
@@ -586,10 +598,10 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
586
598
  )}
587
599
  {canShowDetails && (
588
600
  <>
589
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
590
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
601
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
602
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
591
603
  {ownership && canShowChange && (
592
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
604
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
593
605
  )}
594
606
  </>
595
607
  )}
@@ -810,7 +822,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
810
822
 
811
823
  useShortcut((event) => {
812
824
  if (!focused || viewMode !== "chart") return;
813
- if (event.name === "j" || event.name === "down") {
825
+ if (isPlainKey(event, "j", "down")) {
814
826
  event.preventDefault?.();
815
827
  event.stopPropagation?.();
816
828
  const nextIdx = Math.min((selectedIdx >= 0 ? selectedIdx : 0) + 1, sortedRows.length - 1);
@@ -818,7 +830,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
818
830
  if (nextRow) setSelectedId(nextRow.id);
819
831
  return;
820
832
  }
821
- if (event.name === "k" || event.name === "up") {
833
+ if (isPlainKey(event, "k", "up")) {
822
834
  event.preventDefault?.();
823
835
  event.stopPropagation?.();
824
836
  const nextIdx = Math.max((selectedIdx >= 0 ? selectedIdx : 0) - 1, 0);
@@ -1,4 +1,3 @@
1
- import { saveConfig } from "../../data/config-store";
2
1
  import { findPaneInstance, type LayoutConfig } from "../../types/config";
3
2
  import type { AppNotificationRequest, GloomPlugin, GloomPluginContext } from "../../types/plugin";
4
3
  import type { AppAction } from "../../state/app-context";
@@ -30,15 +29,10 @@ export function clearLayoutManagerDispatch() {
30
29
  getStateRef = null;
31
30
  }
32
31
 
33
- function persistLayout(ctx: Pick<GloomPluginContext, "getConfig">, layout: LayoutConfig) {
32
+ function persistLayout(_ctx: Pick<GloomPluginContext, "getConfig">, layout: LayoutConfig) {
34
33
  if (!dispatchRef) return;
35
34
  dispatchRef({ type: "PUSH_LAYOUT_HISTORY" });
36
35
  dispatchRef({ type: "UPDATE_LAYOUT", layout });
37
- const config = ctx.getConfig();
38
- const layouts = config.layouts.map((savedLayout, index) => (
39
- index === config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
40
- ));
41
- saveConfig({ ...config, layout, layouts }).catch(() => {});
42
36
  }
43
37
 
44
38
  function getFocusedPane(layout: LayoutConfig, focusedPaneId: string | null) {
@@ -158,6 +158,39 @@ function createRows(quotes: ScreenerQuote[]): MarketMoverRow[] {
158
158
  }));
159
159
  }
160
160
 
161
+ function summaryQuoteFromQuote(
162
+ symbol: string,
163
+ quote: { name?: string; price: number; change: number; changePercent: number },
164
+ ): MarketSummaryQuote {
165
+ return {
166
+ symbol,
167
+ name: quote.name ?? symbol,
168
+ price: quote.price,
169
+ change: quote.change,
170
+ changePercent: quote.changePercent,
171
+ };
172
+ }
173
+
174
+ function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string }): ScreenerQuote {
175
+ return {
176
+ symbol,
177
+ name: quote.name ?? symbol,
178
+ price: quote.price ?? 0,
179
+ change: quote.change ?? 0,
180
+ changePercent: quote.changePercent ?? 0,
181
+ volume: quote.volume ?? 0,
182
+ avgVolume: 0,
183
+ volumeRatio: 0,
184
+ marketCap: undefined,
185
+ currency: quote.currency ?? "USD",
186
+ fiftyTwoWeekHigh: undefined,
187
+ fiftyTwoWeekLow: undefined,
188
+ dayHigh: undefined,
189
+ dayLow: undefined,
190
+ exchange: "",
191
+ };
192
+ }
193
+
161
194
  function buildColumns(width: number): MarketMoverColumn[] {
162
195
  const rankWidth = 3;
163
196
  const tickerWidth = 8;
@@ -227,28 +260,34 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
227
260
  useEffect(() => {
228
261
  if (!dataProvider) return;
229
262
  const loadSummary = async () => {
230
- const results: MarketSummaryQuote[] = [];
231
- await Promise.allSettled(
232
- MARKET_SUMMARY_SYMBOLS.map(async (symbol) => {
233
- try {
234
- const q = await dataProvider.getQuote(symbol, "");
235
- if (q) {
236
- results.push({
237
- symbol,
238
- name: q.name ?? symbol,
239
- price: q.price,
240
- change: q.change,
241
- changePercent: q.changePercent,
242
- });
243
- }
244
- } catch { /* skip */ }
245
- }),
246
- );
247
- // Preserve the original symbol order
263
+ if (dataProvider.getQuotesBatch) {
264
+ const batchResults = await dataProvider.getQuotesBatch(
265
+ MARKET_SUMMARY_SYMBOLS.map((symbol) => ({ symbol, exchange: "" })),
266
+ ).catch(() => []);
267
+ const bySymbol = new Map(batchResults.map((result) => [result.target.symbol, result]));
268
+ setSummaryQuotes(
269
+ MARKET_SUMMARY_SYMBOLS
270
+ .map((symbol) => {
271
+ const result = bySymbol.get(symbol);
272
+ return result?.quote ? summaryQuoteFromQuote(symbol, result.quote) : null;
273
+ })
274
+ .filter((quote): quote is MarketSummaryQuote => !!quote),
275
+ );
276
+ return;
277
+ }
278
+
279
+ const results = await Promise.all(MARKET_SUMMARY_SYMBOLS.map(async (symbol) => {
280
+ try {
281
+ const quote = await dataProvider.getQuote(symbol, "");
282
+ return quote ? summaryQuoteFromQuote(symbol, quote) : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }));
248
287
  setSummaryQuotes(
249
288
  MARKET_SUMMARY_SYMBOLS
250
289
  .map((s) => results.find((r) => r.symbol === s))
251
- .filter((r): r is MarketSummaryQuote => r !== undefined),
290
+ .filter((r): r is MarketSummaryQuote => !!r),
252
291
  );
253
292
  };
254
293
  loadSummary();
@@ -280,33 +319,26 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
280
319
  const resolved: ScreenerQuote[] = [];
281
320
 
282
321
  if (dataProvider) {
283
- await Promise.allSettled(
284
- trending.slice(0, 25).map(async ({ symbol }) => {
322
+ const targets = trending.slice(0, 25).map(({ symbol }) => ({ symbol, exchange: "" }));
323
+ if (dataProvider.getQuotesBatch) {
324
+ const batchResults = await dataProvider.getQuotesBatch(targets).catch(() => []);
325
+ for (const result of batchResults) {
326
+ if (fetchGenRef.current !== gen) return;
327
+ if (result.quote) {
328
+ resolved.push(screenerQuoteFromQuote(result.target.symbol, result.quote));
329
+ }
330
+ }
331
+ } else {
332
+ await Promise.allSettled(targets.map(async ({ symbol }) => {
285
333
  try {
286
334
  const q = await dataProvider.getQuote(symbol, "");
287
335
  if (fetchGenRef.current !== gen) return;
288
336
  if (q) {
289
- resolved.push({
290
- symbol,
291
- name: q.name ?? symbol,
292
- price: q.price ?? 0,
293
- change: q.change ?? 0,
294
- changePercent: q.changePercent ?? 0,
295
- volume: q.volume ?? 0,
296
- avgVolume: 0,
297
- volumeRatio: 0,
298
- marketCap: undefined,
299
- currency: q.currency ?? "USD",
300
- fiftyTwoWeekHigh: undefined,
301
- fiftyTwoWeekLow: undefined,
302
- dayHigh: undefined,
303
- dayLow: undefined,
304
- exchange: "",
305
- });
337
+ resolved.push(screenerQuoteFromQuote(symbol, q));
306
338
  }
307
339
  } catch { /* skip */ }
308
- }),
309
- );
340
+ }));
341
+ }
310
342
  }
311
343
 
312
344
  if (fetchGenRef.current !== gen) return;
@@ -0,0 +1,156 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane-footer";
4
+ import type { NewsService } from "../../../news/aggregator";
5
+ import { setSharedNewsService } from "../../../news/hooks";
6
+ import type { NewsArticle, NewsQueryState } from "../../../news/types";
7
+ import { testRender } from "../../../renderers/opentui/test-utils";
8
+ import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app-context";
9
+ import { createStatefulTestPluginRuntime } from "../../../test-support/plugin-runtime";
10
+ import { createDefaultConfig } from "../../../types/config";
11
+ import { Box } from "../../../ui";
12
+ import { PluginRenderProvider } from "../../plugin-runtime";
13
+ import { __setDetectedProvidersForTests } from "../ai/providers";
14
+ import { setAiRunHost } from "../ai/runner";
15
+ import { BreakingPane } from "./breaking-pane";
16
+
17
+ const PANE_ID = "news-breaking:test";
18
+
19
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
20
+
21
+ function makeArticle(): NewsArticle {
22
+ return {
23
+ id: "story-1",
24
+ title: "Chip stocks rally on new AI demand",
25
+ url: "https://example.com/story",
26
+ source: "example",
27
+ publishedAt: new Date("2026-05-13T12:00:00.000Z"),
28
+ summary: "Semiconductor names moved higher after hyperscaler capex commentary.",
29
+ topic: "earnings",
30
+ topics: ["earnings"],
31
+ sectors: ["information_technology"],
32
+ categories: ["earnings", "information_technology"],
33
+ tickers: ["AMD", "NVDA"],
34
+ sentiment: "positive",
35
+ scores: {
36
+ importance: 85,
37
+ urgency: 80,
38
+ marketImpact: 75,
39
+ novelty: 60,
40
+ confidence: 90,
41
+ },
42
+ isBreaking: true,
43
+ isDeveloping: false,
44
+ importance: 85,
45
+ };
46
+ }
47
+
48
+ function createReadyNewsService(articles: NewsArticle[]): { service: NewsService; getQueryStateCalls: () => number } {
49
+ const state: NewsQueryState = {
50
+ phase: "ready",
51
+ articles,
52
+ error: null,
53
+ updatedAt: Date.now(),
54
+ sourceIds: ["test"],
55
+ };
56
+ const listeners = new Set<() => void>();
57
+ let queryStateCalls = 0;
58
+ const service = {
59
+ subscribe(listener: () => void) {
60
+ listeners.add(listener);
61
+ return () => {
62
+ listeners.delete(listener);
63
+ };
64
+ },
65
+ getVersion() {
66
+ return 1;
67
+ },
68
+ getQueryState() {
69
+ queryStateCalls += 1;
70
+ return state;
71
+ },
72
+ async load() {
73
+ return state;
74
+ },
75
+ async loadStory() {
76
+ return null;
77
+ },
78
+ } as unknown as NewsService;
79
+ return { service, getQueryStateCalls: () => queryStateCalls };
80
+ }
81
+
82
+ function createHarness() {
83
+ const config = createDefaultConfig("/tmp/gloomberb-breaking-news");
84
+ config.layout.instances.push({
85
+ instanceId: PANE_ID,
86
+ paneId: "news-breaking",
87
+ title: "Breaking News",
88
+ });
89
+ const state = createInitialState(config);
90
+ state.focusedPaneId = PANE_ID;
91
+
92
+ return (
93
+ <AppContext value={{ state, dispatch: () => {} }}>
94
+ <PaneInstanceProvider paneId={PANE_ID}>
95
+ <PluginRenderProvider pluginId="news" runtime={createStatefulTestPluginRuntime()}>
96
+ <PaneFooterProvider>
97
+ {(footer) => (
98
+ <Box flexDirection="column" width={90} height={18}>
99
+ <BreakingPane focused width={90} height={17} />
100
+ <PaneFooterBar footer={footer} focused width={90} />
101
+ </Box>
102
+ )}
103
+ </PaneFooterProvider>
104
+ </PluginRenderProvider>
105
+ </PaneInstanceProvider>
106
+ </AppContext>
107
+ );
108
+ }
109
+
110
+ afterEach(async () => {
111
+ setSharedNewsService(null);
112
+ setAiRunHost(null);
113
+ __setDetectedProvidersForTests(null);
114
+ if (testSetup) {
115
+ await act(async () => {
116
+ testSetup!.renderer.destroy();
117
+ });
118
+ testSetup = undefined;
119
+ }
120
+ });
121
+
122
+ describe("BreakingPane", () => {
123
+ test("does not run local AI providers when breaking news is mounted", async () => {
124
+ let runCalls = 0;
125
+ const newsService = createReadyNewsService([makeArticle()]);
126
+ setSharedNewsService(newsService.service);
127
+ __setDetectedProvidersForTests([
128
+ {
129
+ id: "claude",
130
+ name: "Claude",
131
+ command: "claude",
132
+ available: true,
133
+ buildArgs: () => [],
134
+ },
135
+ ]);
136
+ setAiRunHost({
137
+ run() {
138
+ runCalls += 1;
139
+ return {
140
+ done: Promise.resolve("digest"),
141
+ cancel: () => {},
142
+ };
143
+ },
144
+ });
145
+
146
+ await act(async () => {
147
+ testSetup = await testRender(createHarness(), { width: 90, height: 18 });
148
+ await Bun.sleep(20);
149
+ await testSetup.renderOnce();
150
+ await testSetup.renderOnce();
151
+ });
152
+
153
+ expect(runCalls).toBe(0);
154
+ expect(newsService.getQueryStateCalls()).toBeGreaterThan(0);
155
+ });
156
+ });
@@ -1,13 +1,8 @@
1
1
  import { Box } from "../../../ui";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
2
  import type { PaneProps } from "../../../types/plugin";
4
3
  import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
5
4
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
6
5
  import { Spinner } from "../../../components";
7
- import type { MarketNewsItem } from "../../../types/news-source";
8
- import { detectProviders, getAiProvider, resolveDefaultAiProviderId } from "../ai/providers";
9
- import { runAiPrompt } from "../ai/runner";
10
- import { getDigest, setDigest, isDigestInFlight, markDigestInFlight, clearDigestInFlight } from "./digest-store";
11
6
  import { NewsDetailView, useNewsArticleDetail } from "./news-detail-view";
12
7
  import { NewsArticleStackView, type NewsSortPreference } from "./news-table";
13
8
  import { useNewsArticleFooter } from "./news-footer";
@@ -15,19 +10,7 @@ import { NEWS_QUERY_PRESETS } from "./news-query-presets";
15
10
  import { usePersistedNewsArticles } from "./persisted-articles";
16
11
  import { useNewsReadState } from "./read-state";
17
12
 
18
- const DIGEST_PROMPT = `You are a financial news wire editor. Condense this headline and summary into a single concise actionable bullet point for a professional trader. Include why it matters and potential market impact. Keep it under 120 characters. Respond with ONLY the bullet text, nothing else.
19
-
20
- Headline: {title}
21
- Summary: {summary}`;
22
-
23
13
  const DEFAULT_SORT: NewsSortPreference = { columnId: "importance", direction: "desc" };
24
- const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
-
26
- function buildDigestPrompt(item: MarketNewsItem): string {
27
- return DIGEST_PROMPT
28
- .replace("{title}", item.title)
29
- .replace("{summary}", item.summary ?? item.title);
30
- }
31
14
 
32
15
  export function BreakingPane({ focused, width, height }: PaneProps) {
33
16
  const breakingState = useNewsArticles(NEWS_QUERY_PRESETS.breaking);
@@ -35,86 +18,14 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
35
18
  const loading = breakingState.phase === "loading" || (breakingState.phase === "refreshing" && articles.length === 0);
36
19
  const [selectedArticleId, setSelectedArticleId] = useDebouncedPluginPaneState<string | null>("breaking:selectedArticleId", null);
37
20
  const [sortPreference, setSortPreference] = usePluginPaneState<NewsSortPreference>("breaking:sort", DEFAULT_SORT);
38
- const [digestVersion, setDigestVersion] = useState(0);
39
- const [aiAvailable, setAiAvailable] = useState(false);
40
- const [aiError, setAiError] = useState<string | null>(null);
41
- const [aiRunning, setAiRunning] = useState(false);
42
- const [spinFrame, setSpinFrame] = useState(0);
43
- const processingRef = useRef(false);
44
21
  const loadNewsStory = useLoadNewsStory();
45
22
  const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
46
23
  const { readArticleIds, markArticleRead } = useNewsReadState();
47
24
 
48
- useEffect(() => {
49
- const providers = detectProviders();
50
- setAiAvailable(providers.some((provider) => provider.available));
51
- }, []);
52
-
53
- useEffect(() => {
54
- if (!aiRunning) return;
55
- const id = setInterval(() => setSpinFrame((frame) => (frame + 1) % BRAILLE_FRAMES.length), 80);
56
- return () => clearInterval(id);
57
- }, [aiRunning]);
58
-
59
- useEffect(() => {
60
- if (!aiAvailable || articles.length === 0) return;
61
- if (processingRef.current) return;
62
-
63
- const providerId = resolveDefaultAiProviderId();
64
- const provider = getAiProvider(providerId);
65
- if (!provider?.available) return;
66
-
67
- const toDigest = articles.filter((article) => !getDigest(article.id) && !isDigestInFlight(article.id));
68
- if (toDigest.length === 0) return;
69
-
70
- processingRef.current = true;
71
- setAiRunning(true);
72
-
73
- (async () => {
74
- for (const article of toDigest) {
75
- if (getDigest(article.id) || isDigestInFlight(article.id)) continue;
76
- markDigestInFlight(article.id);
77
-
78
- try {
79
- const result = await runAiPrompt({
80
- provider,
81
- prompt: buildDigestPrompt(article),
82
- }).done;
83
-
84
- const digest = result.trim().slice(0, 150);
85
- if (digest) {
86
- setDigest(article.id, digest);
87
- setDigestVersion((version) => version + 1);
88
- }
89
- } catch (error) {
90
- const message = error instanceof Error ? error.message : String(error);
91
- if (message.includes("Credit") || message.includes("quota") || message.includes("rate limit")) {
92
- setAiError(message);
93
- break;
94
- }
95
- } finally {
96
- clearDigestInFlight(article.id);
97
- }
98
- }
99
- setAiRunning(false);
100
- processingRef.current = false;
101
- })();
102
- }, [aiAvailable, articles]);
103
-
104
- const titleForArticle = useCallback((article: MarketNewsItem) => (
105
- getDigest(article.id) ?? article.title
106
- ), [digestVersion]);
107
-
108
- const footerInfo = useMemo(() => [
109
- ...(aiRunning ? [{ id: "running", parts: [{ text: BRAILLE_FRAMES[spinFrame] ?? "running", tone: "positive" as const }] }] : []),
110
- ...(aiError ? [{ id: "paused", parts: [{ text: "AI paused", tone: "warning" as const }] }] : []),
111
- ], [aiError, aiRunning, spinFrame]);
112
-
113
25
  useNewsArticleFooter({
114
26
  registrationId: "news-wire:breaking",
115
27
  focused,
116
28
  article: detailArticle,
117
- info: footerInfo,
118
29
  });
119
30
 
120
31
  const detailContent = detailArticle ? (
@@ -152,7 +63,6 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
152
63
  columns={["time", "title", "tickers", "importance"]}
153
64
  emptyStateTitle="No breaking news"
154
65
  emptyStateHint="Breaking stories appear when high-priority headlines arrive."
155
- titleForArticle={titleForArticle}
156
66
  />
157
67
  );
158
68
  }
@@ -8,7 +8,6 @@ import {
8
8
  BREAKING_NEWS_NOTIFICATIONS_ENABLED_KEY,
9
9
  setupBreakingNewsNotifications,
10
10
  } from "./breaking-notifications";
11
- import { setDigestPersistence } from "./digest-store";
12
11
  import {
13
12
  addUserNewsFeed,
14
13
  getEnabledNewsFeeds,
@@ -39,7 +38,7 @@ const TopPane = createNewsPresetPane({
39
38
  paneKey: "top",
40
39
  title: "Top News",
41
40
  query: NEWS_QUERY_PRESETS.top,
42
- columns: ["rank", "time", "source", "title", "tickers", "importance"],
41
+ columns: ["time", "title", "tickers", "importance"],
43
42
  defaultSort: { columnId: "importance", direction: "desc" },
44
43
  emptyStateTitle: "No top stories yet",
45
44
  emptyStateHint: "Try refreshing later as new headlines are ranked.",
@@ -56,7 +55,6 @@ const FeedPane = createNewsPresetPane({
56
55
  });
57
56
 
58
57
  export function registerNewsWireFeatures(ctx: GloomPluginContext): () => void {
59
- setDigestPersistence(ctx.persistence);
60
58
  ctx.registerPane({ id: "news-top", name: "Top News", icon: "T", component: TopPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 90, height: 30 } });
61
59
  ctx.registerPane({ id: "news-feed", name: "News Feed", icon: "N", component: FeedPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 35 } });
62
60
  ctx.registerPane({ id: "news-industry", name: "Sector News", icon: "S", component: IndustryPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 35 } });
@@ -8,6 +8,7 @@ import { TickerBadge } from "../../../components/ticker-badge";
8
8
  import { ExternalLink, ExternalLinkText } from "../../../components/ui";
9
9
  import { collectNewsDisplayTickers } from "../../../news/ticker-symbols";
10
10
  import { useInlineTickers } from "../../../state/use-inline-tickers";
11
+ import { isPlainKey } from "../../../utils/keyboard";
11
12
  import { wrapTextLines } from "../../../utils/text-wrap";
12
13
 
13
14
  function hasStoryItems(article: MarketNewsItem | null): boolean {
@@ -196,13 +197,13 @@ export function NewsDetailView({ item, focused, width, showTitle = true }: {
196
197
 
197
198
  useShortcut((event) => {
198
199
  if (!focused) return;
199
- if (event.name === "j" || event.name === "down") {
200
+ if (isPlainKey(event, "j", "down")) {
200
201
  event.stopPropagation?.();
201
202
  event.preventDefault?.();
202
203
  scrollBy(1);
203
204
  return;
204
205
  }
205
- if (event.name === "k" || event.name === "up") {
206
+ if (isPlainKey(event, "k", "up")) {
206
207
  event.stopPropagation?.();
207
208
  event.preventDefault?.();
208
209
  scrollBy(-1);
@@ -45,6 +45,9 @@ export function NewsPresetPane({
45
45
  `${paneKey}:sort`,
46
46
  defaultSort,
47
47
  );
48
+ const effectiveSortPreference = columns.includes(sortPreference.columnId)
49
+ ? sortPreference
50
+ : defaultSort;
48
51
  const loadNewsStory = useLoadNewsStory();
49
52
  const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
50
53
  const { readArticleIds, markArticleRead } = useNewsReadState();
@@ -79,7 +82,7 @@ export function NewsPresetPane({
79
82
  readArticleIds={readArticleIds}
80
83
  selectedArticleId={selectedArticleId}
81
84
  setSelectedArticleId={setSelectedArticleId}
82
- sortPreference={sortPreference}
85
+ sortPreference={effectiveSortPreference}
83
86
  setSortPreference={setSortPreference}
84
87
  onOpenArticle={openArticle}
85
88
  onArticleRead={markArticleRead}
@@ -65,10 +65,16 @@ function samePersistedArticles(left: PersistedNewsArticle[], right: PersistedNew
65
65
  if (left.length !== right.length) return false;
66
66
  return left.every((article, index) => {
67
67
  const other = right[index];
68
- return other?.id === article.id && other.publishedAt === article.publishedAt;
68
+ return other?.id === article.id &&
69
+ other.publishedAt === article.publishedAt &&
70
+ storyItemsSignature(other.items) === storyItemsSignature(article.items);
69
71
  });
70
72
  }
71
73
 
74
+ function storyItemsSignature(items: PersistedNewsStoryItem[] | undefined): string {
75
+ return items?.map((item) => `${item.id}:${item.publishedAt}`).join("|") ?? "";
76
+ }
77
+
72
78
  export function usePersistedNewsArticles(key: string, articles: MarketNewsItem[]): MarketNewsItem[] {
73
79
  const [persistedArticles, setPersistedArticles] = usePluginPaneState<PersistedNewsArticle[]>(
74
80
  key,