gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -1465,6 +1465,61 @@ describe("ChatContent", () => {
1465
1465
  expect(sentMessages).toEqual(["first line\nsecond line"]);
1466
1466
  });
1467
1467
 
1468
+ test("clears the composer locally after an accepted send", async () => {
1469
+ const controller = createController({ sessionToken: "token-123" });
1470
+ const sentMessages: string[] = [];
1471
+ (controller as any).send = (content: string) => {
1472
+ sentMessages.push(content);
1473
+ return true;
1474
+ };
1475
+ (controller as any).setDraft = () => {};
1476
+
1477
+ await act(async () => {
1478
+ testSetup = await testRender(createHarness(controller), {
1479
+ width: 60,
1480
+ height: 12,
1481
+ });
1482
+ });
1483
+
1484
+ await flushFrame();
1485
+
1486
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
1487
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
1488
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
1489
+
1490
+ expect(inputRow).toBeGreaterThanOrEqual(0);
1491
+ expect(inputCol).toBeGreaterThanOrEqual(0);
1492
+
1493
+ await act(async () => {
1494
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
1495
+ await testSetup!.renderOnce();
1496
+ await testSetup!.renderOnce();
1497
+ });
1498
+
1499
+ await act(async () => {
1500
+ await testSetup!.mockInput.typeText("hello");
1501
+ await testSetup!.renderOnce();
1502
+ await testSetup!.renderOnce();
1503
+ });
1504
+
1505
+ await act(async () => {
1506
+ testSetup!.mockInput.pressEnter();
1507
+ await testSetup!.renderOnce();
1508
+ await testSetup!.renderOnce();
1509
+ });
1510
+
1511
+ expect(sentMessages).toEqual(["hello"]);
1512
+ expect(testSetup.captureCharFrame()).not.toContain("> hello");
1513
+
1514
+ await act(async () => {
1515
+ testSetup!.mockInput.pressEnter();
1516
+ await testSetup!.renderOnce();
1517
+ await testSetup!.renderOnce();
1518
+ });
1519
+
1520
+ expect(sentMessages).toEqual(["hello"]);
1521
+ });
1522
+
1468
1523
  test("keeps typed shortcut letters in the composer instead of moving message selection", async () => {
1469
1524
  const controller = createController({
1470
1525
  messages: Array.from({ length: 18 }, (_, index) => makeMessage(index + 1)),
@@ -1248,17 +1248,31 @@ export function ChatContent({
1248
1248
  return textarea.visualCursor.visualRow >= visualLineCount - 1;
1249
1249
  }, []);
1250
1250
 
1251
+ const clearLocalComposer = useCallback(() => {
1252
+ inputValueRef.current = "";
1253
+ updateComposerRows("");
1254
+ const textarea = inputRef.current;
1255
+ if (textarea && textarea.editBuffer.getText() !== "") {
1256
+ applyingExternalDraftRef.current = true;
1257
+ try {
1258
+ textarea.setText("");
1259
+ } finally {
1260
+ applyingExternalDraftRef.current = false;
1261
+ }
1262
+ }
1263
+ }, [updateComposerRows]);
1264
+
1251
1265
  const sendMessage = useCallback(() => {
1252
1266
  const content = inputValueRef.current.trim();
1253
1267
  if (!content) return;
1254
- if (useDefaultControllerChannel) {
1255
- controller.send(content, replyToRef.current?.id);
1256
- } else {
1257
- controller.sendToChannel(channelId, content, replyToRef.current?.id);
1258
- }
1268
+ const accepted = useDefaultControllerChannel
1269
+ ? controller.send(content, replyToRef.current?.id)
1270
+ : controller.sendToChannel(channelId, content, replyToRef.current?.id);
1271
+ if (!accepted) return;
1272
+ clearLocalComposer();
1259
1273
  setSelectedIdx(-1);
1260
1274
  setFollowMessages(true);
1261
- }, [channelId, controller, useDefaultControllerChannel]);
1275
+ }, [channelId, clearLocalComposer, controller, useDefaultControllerChannel]);
1262
1276
 
1263
1277
  const requestOlderMessages = useCallback(() => {
1264
1278
  const scrollBox = scrollRef.current;
@@ -1,12 +1,22 @@
1
- import { expect, test } from "bun:test";
1
+ import { afterEach, expect, test } from "bun:test";
2
2
  import {
3
3
  buildTreemap,
4
4
  buildTreemapRects,
5
5
  formatHolderOwnershipPercent,
6
6
  resolveHolderOwnershipPercent,
7
+ tileTextColor,
7
8
  } from "./index";
9
+ import { applyTheme, colors } from "../../../theme/colors";
10
+ import { contrastRatio } from "../../../theme/color-utils";
11
+ import { DEFAULT_THEME, themes } from "../../../theme/themes";
8
12
  import type { HolderRecord } from "../../../types/financials";
9
13
 
14
+ const TILE_TEXT_MIN_CONTRAST = 4.5;
15
+
16
+ afterEach(() => {
17
+ applyTheme(DEFAULT_THEME);
18
+ });
19
+
10
20
  function holder(name: string, value: number): HolderRecord & { id: string } {
11
21
  return {
12
22
  id: name,
@@ -55,3 +65,22 @@ test("resolves holder ownership from provider percent before value over market c
55
65
  expect(resolveHolderOwnershipPercent({ value: 120 }, undefined)).toBeUndefined();
56
66
  expect(formatHolderOwnershipPercent(0.085)).toBe("8.50%");
57
67
  });
68
+
69
+ test("keeps holder treemap text readable on semantic tile backgrounds", () => {
70
+ for (const themeId of Object.keys(themes)) {
71
+ applyTheme(themeId);
72
+ const backgrounds = {
73
+ positive: colors.positive,
74
+ negative: colors.negative,
75
+ neutral: colors.neutral,
76
+ selected: colors.selected,
77
+ };
78
+
79
+ for (const [name, background] of Object.entries(backgrounds)) {
80
+ expect(
81
+ contrastRatio(tileTextColor(background), background),
82
+ `${themeId} ${name}`,
83
+ ).toBeGreaterThanOrEqual(TILE_TEXT_MIN_CONTRAST);
84
+ }
85
+ }
86
+ });
@@ -11,7 +11,7 @@ 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
+ import { blendForContrast, higherContrast } from "../../../theme/color-utils";
15
15
  import type { HolderData, HolderRecord } from "../../../types/financials";
16
16
  import type { GloomPlugin } from "../../../types/plugin";
17
17
  import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
@@ -447,12 +447,16 @@ function desktopTileColor(row: HolderRow): string {
447
447
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
448
448
  }
449
449
 
450
- function tileTextColor(backgroundColor: string): string {
451
- return higherContrast(
450
+ const TILE_TEXT_MIN_CONTRAST = 4.5;
451
+
452
+ export function tileTextColor(backgroundColor: string): string {
453
+ const preferred = higherContrast(
452
454
  higherContrast(colors.text, colors.textBright, backgroundColor),
453
455
  colors.selectedText,
454
456
  backgroundColor,
455
457
  );
458
+ const fallback = higherContrast("#000000", "#ffffff", backgroundColor);
459
+ return blendForContrast(preferred, backgroundColor, fallback, TILE_TEXT_MIN_CONTRAST);
456
460
  }
457
461
 
458
462
  function Tile({ tile, selected, currency, marketCap, onSelect }: {
@@ -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,
@@ -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 } });
@@ -3,7 +3,7 @@ import type { ColumnConfig } from "../../../types/config";
3
3
  import type { TickerFinancials } from "../../../types/financials";
4
4
  import type { TickerRecord } from "../../../types/ticker";
5
5
  import { priceColor } from "../../../theme/colors";
6
- import { clampQuoteTimestamp, formatQuoteAgeWithSource } from "../../../utils/quote-time";
6
+ import { formatQuoteAgeWithSource, resolveQuoteAgeTimestamp } from "../../../utils/quote-time";
7
7
  import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
8
8
  import { formatMarketCost, formatMarketPrice, formatMarketQuantity, formatSignedMarketPrice, type MarketFormatOptions } from "../../../utils/market-format";
9
9
  import { getActiveQuoteDisplay, marketStateDot, type ActiveQuoteDisplay } from "../../../utils/market-status";
@@ -389,7 +389,7 @@ export function getSortValue(
389
389
  }
390
390
  return null;
391
391
  case "latency":
392
- return quote?.lastUpdated != null ? ctx.now - (clampQuoteTimestamp(quote.lastUpdated, ctx.now) ?? ctx.now) : null;
392
+ return quote ? ctx.now - (resolveQuoteAgeTimestamp(quote, ctx.now) ?? ctx.now) : null;
393
393
  case PRICE_SPARKLINE_COLUMN_ID: {
394
394
  const values = (financials?.priceHistory ?? [])
395
395
  .map((point) => point.close)