gloomberb 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +63 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +6 -0
  118. package/src/utils/api-client.test.ts +32 -0
  119. package/src/utils/api-client.ts +119 -18
  120. package/src/utils/double-escape-close.test.ts +33 -0
  121. package/src/utils/double-escape-close.ts +40 -0
  122. package/src/utils/throttled-fetch.test.ts +22 -0
  123. package/src/utils/throttled-fetch.ts +3 -1
  124. package/src/utils/ticker-search.ts +53 -9
  125. package/src/version.ts +1 -1
@@ -487,6 +487,26 @@ describe("quote merging", () => {
487
487
  });
488
488
 
489
489
  describe("layout focus fallback", () => {
490
+ test("starts with the top floating pane focused when no session pane is active", () => {
491
+ const config = createDefaultConfig("/tmp/gloomberb-test");
492
+ const layout = {
493
+ ...cloneLayout(config.layout),
494
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
495
+ floating: [
496
+ { instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
497
+ { instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
498
+ ],
499
+ };
500
+
501
+ const state = createInitialState({
502
+ ...config,
503
+ layout,
504
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
505
+ });
506
+
507
+ expect(state.focusedPaneId).toBe("ticker-detail:main");
508
+ });
509
+
490
510
  test("cycles backward from a stale focused pane to the last visible pane", () => {
491
511
  const config = createDefaultConfig("/tmp/gloomberb-test");
492
512
  const state = createInitialState(config);
@@ -13,14 +13,14 @@ import {
13
13
  type ReactNode,
14
14
  } from "react";
15
15
  import type { SessionStore } from "../data/session-store";
16
- import { syncTheme } from "../theme/colors";
16
+ import { ThemeProvider } from "../theme/theme-context";
17
17
  import {
18
18
  findPaneInstance,
19
19
  materializeDetachedPanesAsFloating,
20
20
  type AppConfig,
21
21
  type PaneInstanceConfig,
22
22
  } from "../types/config";
23
- import type { DesktopSharedStateSnapshot, DesktopWindowBridge } from "../types/desktop-window";
23
+ import type { DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "../types/desktop-window";
24
24
  import { setPaneSetting } from "../pane-settings";
25
25
  import { useTickerFinancials } from "../market-data/hooks";
26
26
  import {
@@ -142,7 +142,7 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
142
142
  const context = useRequiredAppContext();
143
143
  const selectorRef = useRef(selector);
144
144
  selectorRef.current = selector;
145
- const lastSnapshotRef = useRef<{ selection: T; themeId: string } | undefined>(undefined);
145
+ const lastSnapshotRef = useRef<{ selection: T } | undefined>(undefined);
146
146
 
147
147
  if (!isAppStoreContextValue(context)) {
148
148
  return selector(context.state);
@@ -153,12 +153,11 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
153
153
  () => {
154
154
  const state = context.getState();
155
155
  const selection = selectorRef.current(state);
156
- const themeId = getEffectiveThemeId(state);
157
156
  const previous = lastSnapshotRef.current;
158
- if (previous && Object.is(previous.selection, selection) && previous.themeId === themeId) {
157
+ if (previous && Object.is(previous.selection, selection)) {
159
158
  return previous;
160
159
  }
161
- const nextSnapshot = { selection, themeId };
160
+ const nextSnapshot = { selection };
162
161
  lastSnapshotRef.current = nextSnapshot;
163
162
  return nextSnapshot;
164
163
  },
@@ -166,7 +165,6 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
166
165
  const state = context.getState();
167
166
  return {
168
167
  selection: selectorRef.current(state),
169
- themeId: getEffectiveThemeId(state),
170
168
  };
171
169
  },
172
170
  );
@@ -303,6 +301,7 @@ export function AppProvider({
303
301
  sessionSnapshot = null,
304
302
  desktopBridge,
305
303
  desktopSnapshot = null,
304
+ initialThemePreview = null,
306
305
  }: {
307
306
  config: AppConfig;
308
307
  children: ReactNode;
@@ -310,34 +309,40 @@ export function AppProvider({
310
309
  sessionSnapshot?: AppSessionSnapshot | null;
311
310
  desktopBridge?: DesktopWindowBridge;
312
311
  desktopSnapshot?: DesktopSharedStateSnapshot | null;
312
+ initialThemePreview?: DesktopThemePreviewState | null;
313
313
  }) {
314
314
  const [state, rawDispatch] = useReducer(
315
315
  appReducer,
316
- { config, sessionSnapshot, desktopSnapshot },
316
+ { config, sessionSnapshot, desktopSnapshot, initialThemePreview },
317
317
  (initialState: {
318
318
  config: AppConfig;
319
319
  sessionSnapshot?: AppSessionSnapshot | null;
320
320
  desktopSnapshot?: DesktopSharedStateSnapshot | null;
321
+ initialThemePreview?: DesktopThemePreviewState | null;
321
322
  }) => {
322
323
  const {
323
324
  config: initialConfig,
324
325
  sessionSnapshot: initialSessionSnapshot,
325
326
  desktopSnapshot: initialDesktopSnapshot,
327
+ initialThemePreview: initialDesktopThemePreview,
326
328
  } = initialState;
327
- const nextState = createInitialState(initialConfig, initialSessionSnapshot);
328
- return initialDesktopSnapshot
329
- ? appReducer(nextState, { type: "HYDRATE_DESKTOP_SNAPSHOT", snapshot: initialDesktopSnapshot })
329
+ const baseState = createInitialState(initialConfig, initialSessionSnapshot);
330
+ const nextState = initialDesktopSnapshot
331
+ ? appReducer(baseState, { type: "HYDRATE_DESKTOP_SNAPSHOT", snapshot: initialDesktopSnapshot })
332
+ : baseState;
333
+ return initialDesktopThemePreview
334
+ ? appReducer(nextState, { type: "PREVIEW_THEME", theme: initialDesktopThemePreview.theme })
330
335
  : nextState;
331
336
  },
332
337
  );
333
- // Sync the current theme before descendants read the shared palette during this render.
334
- syncTheme(getEffectiveThemeId(state));
338
+ const effectiveThemeId = getEffectiveThemeId(state);
335
339
  const previousRecentTickers = useRef(state.recentTickers);
336
340
  const stateRef = useRef(state);
337
341
  const listenersRef = useRef(new Set<() => void>());
338
342
  const storeRef = useRef<AppContextStoreValue | null>(null);
339
343
  const lastMainSyncRef = useRef<string | null>(null);
340
344
  const lastDetachedPaneSyncRef = useRef<string | null>(null);
345
+ const lastThemePreviewSyncRef = useRef<string | null | undefined>(undefined);
341
346
 
342
347
  stateRef.current = state;
343
348
 
@@ -421,12 +426,35 @@ export function AppProvider({
421
426
 
422
427
  useEffect(() => {
423
428
  if (desktopBridge?.kind !== "main" || !desktopBridge.syncMainState) return;
424
- const snapshot = buildDesktopSnapshot(state);
429
+ const snapshot = buildDesktopSnapshot(stateRef.current);
425
430
  const signature = serializeDesktopSnapshot(snapshot);
426
431
  if (signature === lastMainSyncRef.current) return;
427
432
  lastMainSyncRef.current = signature;
428
433
  void desktopBridge.syncMainState(snapshot);
429
- }, [buildDesktopSnapshot, desktopBridge, serializeDesktopSnapshot, state]);
434
+ }, [
435
+ buildDesktopSnapshot,
436
+ desktopBridge,
437
+ serializeDesktopSnapshot,
438
+ state.activePanel,
439
+ state.config,
440
+ state.focusedPaneId,
441
+ state.paneState,
442
+ state.statusBarVisible,
443
+ ]);
444
+
445
+ useEffect(() => {
446
+ if (desktopBridge?.kind !== "main" || !desktopBridge.syncThemePreview) return;
447
+ if (lastThemePreviewSyncRef.current === state.themePreview) return;
448
+ lastThemePreviewSyncRef.current = state.themePreview;
449
+ void desktopBridge.syncThemePreview({ theme: state.themePreview });
450
+ }, [desktopBridge, state.themePreview]);
451
+
452
+ useEffect(() => {
453
+ if (desktopBridge?.kind !== "detached" || !desktopBridge.subscribeThemePreview) return;
454
+ return desktopBridge.subscribeThemePreview((preview) => {
455
+ dispatch({ type: "PREVIEW_THEME", theme: preview.theme });
456
+ });
457
+ }, [desktopBridge, dispatch]);
430
458
 
431
459
  useEffect(() => {
432
460
  if (desktopBridge?.kind !== "detached" || !desktopBridge.replaceDetachedPaneState || !desktopBridge.paneId) return;
@@ -445,5 +473,9 @@ export function AppProvider({
445
473
 
446
474
  usePersistSessionSnapshot(sessionStore, state, APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION);
447
475
 
448
- return <AppContext.Provider value={storeRef.current}>{children}</AppContext.Provider>;
476
+ return (
477
+ <ThemeProvider themeId={effectiveThemeId}>
478
+ <AppContext.Provider value={storeRef.current}>{children}</AppContext.Provider>
479
+ </ThemeProvider>
480
+ );
449
481
  }
@@ -33,7 +33,7 @@ export function useInlineTickers(texts: readonly string[]): {
33
33
  const tickers = useAppSelector((state) => state.tickers);
34
34
  const financials = useAppSelector((state) => state.financials);
35
35
  const registry = getSharedRegistry();
36
- const [, setRefreshVersion] = useState(0);
36
+ const [refreshVersion, setRefreshVersion] = useState(0);
37
37
  const textsKey = texts.join("\u0000");
38
38
  const symbols = useMemo(() => normalizeSymbols(texts), [textsKey]);
39
39
  const symbolsKey = symbols.join("|");
@@ -168,15 +168,18 @@ export function useInlineTickers(texts: readonly string[]): {
168
168
  registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
169
169
  }, [registry]);
170
170
 
171
- const catalog: Record<string, InlineTickerCatalogEntry> = {};
172
- for (const symbol of symbols) {
173
- const ticker = tickers.get(symbol) ?? null;
174
- const quote = financials.get(symbol)?.quote ?? null;
175
- const status: InlineTickerStatus = ticker
176
- ? (quote ? "ready" : quoteMissingSymbols.has(symbol) ? "missing" : "loading")
177
- : (missingSymbols.has(symbol) ? "missing" : "loading");
178
- catalog[symbol] = { status, ticker, quote };
179
- }
171
+ const catalog = useMemo(() => {
172
+ const entries: Record<string, InlineTickerCatalogEntry> = {};
173
+ for (const symbol of symbols) {
174
+ const ticker = tickers.get(symbol) ?? null;
175
+ const quote = financials.get(symbol)?.quote ?? null;
176
+ const status: InlineTickerStatus = ticker
177
+ ? (quote ? "ready" : quoteMissingSymbols.has(symbol) ? "missing" : "loading")
178
+ : (missingSymbols.has(symbol) ? "missing" : "loading");
179
+ entries[symbol] = { status, ticker, quote };
180
+ }
181
+ return entries;
182
+ }, [financials, refreshVersion, symbols, tickers]);
180
183
 
181
184
  return { catalog, openTicker };
182
185
  }
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ applyTheme,
4
+ clearTransientThemePreview,
5
+ getCurrentThemeId,
6
+ previewTheme,
7
+ syncTheme,
8
+ } from "./colors";
9
+ import { DEFAULT_THEME, getTheme } from "./themes";
10
+
11
+ const originalDocument = (globalThis as Record<string, unknown>).document;
12
+
13
+ function setDocument(value: unknown): void {
14
+ Object.defineProperty(globalThis, "document", {
15
+ configurable: true,
16
+ writable: true,
17
+ value,
18
+ });
19
+ }
20
+
21
+ function installDocumentStyleMock() {
22
+ const values = new Map<string, string>();
23
+ setDocument({
24
+ documentElement: {
25
+ style: {
26
+ setProperty(name: string, value: string) {
27
+ values.set(name, value);
28
+ },
29
+ },
30
+ },
31
+ });
32
+ return values;
33
+ }
34
+
35
+ afterEach(() => {
36
+ if (originalDocument === undefined) {
37
+ Reflect.deleteProperty(globalThis, "document");
38
+ } else {
39
+ setDocument(originalDocument);
40
+ }
41
+ clearTransientThemePreview();
42
+ syncTheme(DEFAULT_THEME);
43
+ });
44
+
45
+ describe("theme colors", () => {
46
+ test("syncs CSS variables when a theme is applied", () => {
47
+ const values = installDocumentStyleMock();
48
+ const theme = getTheme("midnight");
49
+
50
+ applyTheme("midnight");
51
+
52
+ expect(values.get("--gloom-bg")).toBe(theme.bg);
53
+ expect(values.get("--gloom-panel")).toBe(theme.panel);
54
+ expect(values.get("--gloom-text-dim")).toBe(theme.textDim);
55
+ expect(values.get("--gloom-selected")).toBe(theme.selected);
56
+ expect(values.get("--gloom-hover-bg")).toBeString();
57
+ });
58
+
59
+ test("does not let provider sync clobber a pending preview", () => {
60
+ const values = installDocumentStyleMock();
61
+ const preview = getTheme("midnight");
62
+
63
+ previewTheme("midnight");
64
+ syncTheme(DEFAULT_THEME);
65
+
66
+ expect(getCurrentThemeId()).toBe("midnight");
67
+ expect(values.get("--gloom-bg")).toBe(preview.bg);
68
+
69
+ clearTransientThemePreview();
70
+ syncTheme(DEFAULT_THEME);
71
+
72
+ expect(getCurrentThemeId()).toBe(DEFAULT_THEME);
73
+ expect(values.get("--gloom-bg")).toBe(getTheme(DEFAULT_THEME).bg);
74
+ });
75
+ });
@@ -5,7 +5,33 @@ import { getTheme, DEFAULT_THEME, type Theme } from "./themes";
5
5
  // React components re-read these on each render triggered by the SET_THEME action.
6
6
  export const colors: Omit<Theme, "name" | "description"> = { ...getTheme(DEFAULT_THEME) };
7
7
 
8
+ export type ColorKey = keyof typeof colors;
9
+
8
10
  let currentThemeId = DEFAULT_THEME;
11
+ let transientPreviewThemeId: string | null = null;
12
+ let cssThemeId: string | null = null;
13
+ let cssThemeDocument: unknown = null;
14
+
15
+ const THEME_CSS_VARIABLES: Array<[ColorKey, string]> = [
16
+ ["bg", "--gloom-bg"],
17
+ ["panel", "--gloom-panel"],
18
+ ["border", "--gloom-border"],
19
+ ["borderFocused", "--gloom-border-focused"],
20
+ ["text", "--gloom-text"],
21
+ ["textDim", "--gloom-text-dim"],
22
+ ["textBright", "--gloom-text-bright"],
23
+ ["textMuted", "--gloom-text-muted"],
24
+ ["positive", "--gloom-positive"],
25
+ ["negative", "--gloom-negative"],
26
+ ["neutral", "--gloom-neutral"],
27
+ ["warning", "--gloom-warning"],
28
+ ["header", "--gloom-header"],
29
+ ["headerText", "--gloom-header-text"],
30
+ ["selected", "--gloom-selected"],
31
+ ["selectedText", "--gloom-selected-text"],
32
+ ["commandBg", "--gloom-command-bg"],
33
+ ["commandBorder", "--gloom-command-border"],
34
+ ];
9
35
 
10
36
  export function getCurrentThemeId(): string {
11
37
  return currentThemeId;
@@ -16,14 +42,34 @@ export function applyTheme(id: string): void {
16
42
  currentThemeId = id;
17
43
  // Mutate in-place so every existing import sees the new values
18
44
  Object.assign(colors, theme);
45
+ syncThemeCssVariables();
19
46
  }
20
47
 
21
- export function syncTheme(id: string): void {
22
- if (currentThemeId === id) return;
48
+ export function previewTheme(id: string): void {
49
+ transientPreviewThemeId = id;
23
50
  applyTheme(id);
24
51
  }
25
52
 
26
- export type ColorKey = keyof typeof colors;
53
+ export function clearTransientThemePreview(): void {
54
+ transientPreviewThemeId = null;
55
+ }
56
+
57
+ export function syncTheme(id: string): void {
58
+ if (
59
+ transientPreviewThemeId
60
+ && currentThemeId === transientPreviewThemeId
61
+ && id !== transientPreviewThemeId
62
+ ) {
63
+ syncThemeCssVariables();
64
+ return;
65
+ }
66
+ transientPreviewThemeId = null;
67
+ if (currentThemeId === id) {
68
+ syncThemeCssVariables();
69
+ return;
70
+ }
71
+ applyTheme(id);
72
+ }
27
73
 
28
74
  export { blendHex } from "./color-utils";
29
75
 
@@ -49,6 +95,21 @@ export function hoverBg(): string {
49
95
  return blendHex(colors.bg, colors.selected, 0.5);
50
96
  }
51
97
 
98
+ function syncThemeCssVariables(): void {
99
+ const documentLike = (globalThis as {
100
+ document?: { documentElement?: { style?: { setProperty: (name: string, value: string) => void } } };
101
+ }).document;
102
+ const style = documentLike?.documentElement?.style;
103
+ if (!style) return;
104
+ if (cssThemeId === currentThemeId && cssThemeDocument === documentLike) return;
105
+ cssThemeId = currentThemeId;
106
+ cssThemeDocument = documentLike;
107
+ for (const [key, name] of THEME_CSS_VARIABLES) {
108
+ style.setProperty(name, colors[key]);
109
+ }
110
+ style.setProperty("--gloom-hover-bg", hoverBg());
111
+ }
112
+
52
113
  export function commandBarBg(): string {
53
114
  const base = higherContrast(colors.commandBg, colors.panel, colors.bg);
54
115
  const accent = higherContrast(colors.textBright, colors.borderFocused, base);
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import { colors, getCurrentThemeId, syncTheme } from "./colors";
3
+ import type { Theme } from "./themes";
4
+
5
+ type ThemeColors = Omit<Theme, "name" | "description">;
6
+
7
+ interface ThemeContextValue {
8
+ themeId: string;
9
+ colors: ThemeColors;
10
+ }
11
+
12
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
13
+
14
+ export function ThemeProvider({ themeId, children }: { themeId: string; children: ReactNode }) {
15
+ syncTheme(themeId);
16
+ const value = useMemo(() => ({ themeId, colors }), [themeId]);
17
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
18
+ }
19
+
20
+ export function useThemeId(): string {
21
+ return useContext(ThemeContext)?.themeId ?? getCurrentThemeId();
22
+ }
23
+
24
+ export function useThemeColors(): ThemeColors {
25
+ return useContext(ThemeContext)?.colors ?? colors;
26
+ }
@@ -6,6 +6,7 @@ export interface NewsDataProvider {
6
6
  supports?(query: NewsQuery): boolean;
7
7
  getCachedNews?(query: NewsQuery): NewsArticle[];
8
8
  fetchNews(query: NewsQuery): Promise<NewsArticle[]>;
9
+ fetchNewsStory?(storyId: string): Promise<NewsArticle | null>;
9
10
  }
10
11
 
11
12
  export interface CapabilityRouteSource {
@@ -92,6 +92,18 @@ export interface QuoteSubscriptionTarget {
92
92
  route?: "auto" | "provider" | "broker";
93
93
  }
94
94
 
95
+ export interface QuoteBatchResult {
96
+ target: QuoteSubscriptionTarget;
97
+ quote: Quote | null;
98
+ error?: unknown;
99
+ }
100
+
101
+ export interface TickerFinancialsBatchResult {
102
+ target: CachedFinancialsTarget;
103
+ financials: TickerFinancials | null;
104
+ error?: unknown;
105
+ }
106
+
95
107
  export interface AssetDataProvider {
96
108
  readonly id: string;
97
109
  readonly name: string;
@@ -99,7 +111,9 @@ export interface AssetDataProvider {
99
111
  readonly cachePolicy?: CachePolicyMap;
100
112
 
101
113
  canProvide?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<boolean> | boolean;
102
- getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean }): Map<string, TickerFinancials>;
114
+ getCachedFinancialsForTargets?(targets: CachedFinancialsTarget[], options?: { allowExpired?: boolean }): Map<string, TickerFinancials> | Promise<Map<string, TickerFinancials>>;
115
+ getQuotesBatch?(targets: QuoteSubscriptionTarget[], options?: { forceRefresh?: boolean }): Promise<QuoteBatchResult[]>;
116
+ getTickerFinancialsBatch?(targets: CachedFinancialsTarget[], options?: { forceRefresh?: boolean }): Promise<TickerFinancialsBatchResult[]>;
103
117
  getTickerFinancials(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<TickerFinancials>;
104
118
  getQuote(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<Quote>;
105
119
  getExchangeRate(fromCurrency: string): Promise<number>;
@@ -15,10 +15,15 @@ export interface DesktopDockPreviewState {
15
15
  edge: "left" | "right" | "top" | "bottom" | null;
16
16
  }
17
17
 
18
+ export interface DesktopThemePreviewState {
19
+ theme: string | null;
20
+ }
21
+
18
22
  export interface DesktopWindowBridge {
19
23
  kind: "main" | "detached";
20
24
  paneId?: string;
21
25
  syncMainState?(snapshot: DesktopSharedStateSnapshot): Promise<void>;
26
+ syncThemePreview?(preview: DesktopThemePreviewState): Promise<void>;
22
27
  replaceDetachedPaneState?(paneId: string, paneState: PaneRuntimeState): Promise<void>;
23
28
  popOutPane?(paneId: string): Promise<void>;
24
29
  dockDetachedPane?(paneId: string): Promise<void>;
@@ -26,4 +31,5 @@ export interface DesktopWindowBridge {
26
31
  focusDetachedPane?(paneId: string): Promise<void>;
27
32
  subscribeState(listener: (snapshot: DesktopSharedStateSnapshot) => void): () => void;
28
33
  subscribeDockPreview?(listener: (preview: DesktopDockPreviewState) => void): () => void;
34
+ subscribeThemePreview?(listener: (preview: DesktopThemePreviewState) => void): () => void;
29
35
  }
@@ -141,6 +141,8 @@ export interface AnalystRatingRecord {
141
141
  action?: string;
142
142
  current?: string;
143
143
  prior?: string;
144
+ currentPriceTarget?: number;
145
+ priorPriceTarget?: number;
144
146
  }
145
147
 
146
148
  export interface AnalystEstimateRecord {
@@ -449,7 +449,7 @@ export interface GloomPluginContext {
449
449
  hidePane(paneId: string): void;
450
450
  focusPane(paneId: string): void;
451
451
  pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
452
- navigateTicker(symbol: string): void;
452
+ navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
453
453
  openPaneSettings(paneId?: string): void;
454
454
 
455
455
  on<K extends keyof PluginEvents>(event: K, handler: (payload: PluginEvents[K]) => void): () => void;
package/src/ui/host.tsx CHANGED
@@ -98,13 +98,19 @@ export interface ScrollBoxRenderable {
98
98
  scrollTop: number;
99
99
  scrollLeft?: number;
100
100
  scrollHeight: number;
101
+ scrollTopPx?: number;
102
+ scrollLeftPx?: number;
103
+ scrollHeightPx?: number;
104
+ scrollWidthPx?: number;
101
105
  viewport?: { width: number; height: number };
106
+ viewportPx?: { width: number; height: number };
102
107
  visible?: boolean;
103
108
  parent?: unknown;
104
109
  getBoundingClientRect?: () => { x: number; y: number; width: number; height: number };
105
110
  horizontalScrollBar?: { visible: boolean };
106
111
  verticalScrollBar?: { visible: boolean };
107
112
  scrollTo(target: number | { x?: number; y?: number }, y?: number): void;
113
+ scrollToPixels?(target: number | { x?: number; y?: number }, y?: number): void;
108
114
  }
109
115
 
110
116
  export interface InputRenderable {
@@ -320,4 +320,36 @@ describe("apiClient cloud news", () => {
320
320
  expect(url.searchParams.get("cursor")).toBe("cursor-1");
321
321
  expect(result).toEqual({ items: [], nextCursor: null });
322
322
  });
323
+
324
+ test("fetches news story details from the story route", async () => {
325
+ let seenUrl = "";
326
+ globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
327
+ seenUrl = String(input);
328
+ return createResponse({
329
+ id: "story-1",
330
+ headline: "Story headline",
331
+ summary: "Story summary",
332
+ category: "general",
333
+ sentiment: "neutral",
334
+ sectors: [],
335
+ firstPublishedAt: "2026-04-01T10:00:00.000Z",
336
+ lastPublishedAt: "2026-04-01T10:05:00.000Z",
337
+ firstSeenAt: "2026-04-01T10:00:10.000Z",
338
+ lastSeenAt: "2026-04-01T10:05:10.000Z",
339
+ primaryUrl: "https://example.com/story",
340
+ primarySource: "example-wire",
341
+ variantCount: 2,
342
+ sourceCount: 2,
343
+ sources: ["example-wire"],
344
+ entities: [],
345
+ tickerLinks: [],
346
+ items: [],
347
+ });
348
+ });
349
+
350
+ const story = await apiClient.getCloudNewsStory("story-1");
351
+
352
+ expect(new URL(seenUrl).pathname).toBe("/news/story-1");
353
+ expect(story.id).toBe("story-1");
354
+ });
323
355
  });