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.
- package/package.json +1 -1
- package/src/app.tsx +59 -30
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +26 -2
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.ts +72 -1
- package/src/version.ts +1 -1
- 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
|
-
|
|
1255
|
-
controller.send(content, replyToRef.current?.id)
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
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 {
|
|
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
|
|
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)
|