gloomberb 0.6.1 → 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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -3,7 +3,9 @@ import { act, useRef, type Dispatch } from "react";
3
3
  import { testRender } from "../renderers/opentui/test-utils";
4
4
  import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
5
5
  import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
6
- import { applyTheme, getCurrentThemeId } from "../theme/colors";
6
+ import { applyTheme } from "../theme/colors";
7
+ import { useThemeId } from "../theme/theme-context";
8
+ import type { DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "../types/desktop-window";
7
9
 
8
10
  const TEST_PANE_ID = "ticker-detail:test";
9
11
 
@@ -20,6 +22,7 @@ function createTickerDetailConfig(symbol: string): AppConfig {
20
22
  binding: { kind: "fixed" as const, symbol },
21
23
  }],
22
24
  floating: [],
25
+ detached: [],
23
26
  };
24
27
 
25
28
  return {
@@ -43,7 +46,46 @@ function PaneTickerHarness() {
43
46
 
44
47
  function ThemeSelectorHarness() {
45
48
  const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
46
- return <text>{`${focusedPaneId ?? "none"}:${getCurrentThemeId()}`}</text>;
49
+ const themeId = useThemeId();
50
+ return <text>{`${focusedPaneId ?? "none"}:${themeId}`}</text>;
51
+ }
52
+
53
+ function FocusSelectorHarness() {
54
+ const renderCountRef = useRef(0);
55
+ renderCountRef.current += 1;
56
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
57
+ return <text>{`${focusedPaneId ?? "none"}:${renderCountRef.current}`}</text>;
58
+ }
59
+
60
+ function createDesktopBridge(
61
+ kind: "main" | "detached",
62
+ calls: {
63
+ mainSnapshots?: DesktopSharedStateSnapshot[];
64
+ themePreviews?: DesktopThemePreviewState[];
65
+ onThemePreviewSubscribe?: (listener: (preview: DesktopThemePreviewState) => void) => void;
66
+ } = {},
67
+ ): DesktopWindowBridge {
68
+ return {
69
+ kind,
70
+ paneId: kind === "detached" ? TEST_PANE_ID : undefined,
71
+ syncMainState: kind === "main"
72
+ ? async (snapshot) => {
73
+ calls.mainSnapshots?.push(snapshot);
74
+ }
75
+ : undefined,
76
+ syncThemePreview: kind === "main"
77
+ ? async (preview) => {
78
+ calls.themePreviews?.push(preview);
79
+ }
80
+ : undefined,
81
+ subscribeState: () => () => {},
82
+ subscribeThemePreview: kind === "detached"
83
+ ? (listener) => {
84
+ calls.onThemePreviewSubscribe?.(listener);
85
+ return () => {};
86
+ }
87
+ : undefined,
88
+ };
47
89
  }
48
90
 
49
91
  describe("pane selectors", () => {
@@ -100,7 +142,28 @@ describe("pane selectors", () => {
100
142
  expect(testSetup.captureCharFrame()).toContain("AAPL:1");
101
143
  });
102
144
 
103
- test("rerenders selector consumers when the theme changes", async () => {
145
+ test("does not rerender non-theme selector consumers when the theme preview changes", async () => {
146
+ testSetup = await testRender(
147
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
148
+ <DispatchCapture />
149
+ <FocusSelectorHarness />
150
+ </AppProvider>,
151
+ { width: 32, height: 4 },
152
+ );
153
+
154
+ await testSetup.renderOnce();
155
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
156
+
157
+ await act(() => {
158
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
159
+ });
160
+
161
+ await testSetup.renderOnce();
162
+ await testSetup.renderOnce();
163
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:1`);
164
+ });
165
+
166
+ test("rerenders theme hook consumers when the theme changes", async () => {
104
167
  testSetup = await testRender(
105
168
  <AppProvider config={createTickerDetailConfig("AAPL")}>
106
169
  <DispatchCapture />
@@ -121,7 +184,7 @@ describe("pane selectors", () => {
121
184
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
122
185
  });
123
186
 
124
- test("rerenders selector consumers when the theme preview changes", async () => {
187
+ test("rerenders theme hook consumers when the theme preview changes", async () => {
125
188
  testSetup = await testRender(
126
189
  <AppProvider config={createTickerDetailConfig("AAPL")}>
127
190
  <DispatchCapture />
@@ -142,6 +205,33 @@ describe("pane selectors", () => {
142
205
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
143
206
  });
144
207
 
208
+ test("set theme updates the theme hook and clears the active preview", async () => {
209
+ testSetup = await testRender(
210
+ <AppProvider config={createTickerDetailConfig("AAPL")}>
211
+ <DispatchCapture />
212
+ <ThemeSelectorHarness />
213
+ </AppProvider>,
214
+ { width: 32, height: 4 },
215
+ );
216
+
217
+ await testSetup.renderOnce();
218
+ await act(() => {
219
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
220
+ });
221
+ await testSetup.renderOnce();
222
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
223
+
224
+ await act(() => {
225
+ capturedDispatch?.({ type: "SET_THEME", theme: "red" });
226
+ });
227
+
228
+ await testSetup.renderOnce();
229
+ await testSetup.renderOnce();
230
+ const frame = testSetup.captureCharFrame();
231
+ expect(frame).toContain(`${TEST_PANE_ID}:red`);
232
+ expect(frame).not.toContain(`${TEST_PANE_ID}:green`);
233
+ });
234
+
145
235
  test("falls back to the committed theme when theme preview clears", async () => {
146
236
  testSetup = await testRender(
147
237
  <AppProvider config={createTickerDetailConfig("AAPL")}>
@@ -181,4 +271,99 @@ describe("pane selectors", () => {
181
271
  await testSetup.renderOnce();
182
272
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
183
273
  });
274
+
275
+ test("uses the initial desktop theme preview on the first provider render", async () => {
276
+ testSetup = await testRender(
277
+ <AppProvider config={createTickerDetailConfig("AAPL")} initialThemePreview={{ theme: "green" }}>
278
+ <ThemeSelectorHarness />
279
+ </AppProvider>,
280
+ { width: 32, height: 4 },
281
+ );
282
+
283
+ await testSetup.renderOnce();
284
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
285
+ });
286
+
287
+ test("desktop main preview sync does not publish the full state snapshot", async () => {
288
+ const mainSnapshots: DesktopSharedStateSnapshot[] = [];
289
+ const themePreviews: DesktopThemePreviewState[] = [];
290
+ const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
291
+
292
+ testSetup = await testRender(
293
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
294
+ <DispatchCapture />
295
+ <ThemeSelectorHarness />
296
+ </AppProvider>,
297
+ { width: 32, height: 4 },
298
+ );
299
+
300
+ await testSetup.renderOnce();
301
+ mainSnapshots.length = 0;
302
+ themePreviews.length = 0;
303
+
304
+ await act(() => {
305
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
306
+ });
307
+
308
+ await testSetup.renderOnce();
309
+ await testSetup.renderOnce();
310
+ expect(themePreviews).toEqual([{ theme: "green" }]);
311
+ expect(mainSnapshots).toHaveLength(0);
312
+ });
313
+
314
+ test("desktop committed theme changes still sync through the config snapshot", async () => {
315
+ const mainSnapshots: DesktopSharedStateSnapshot[] = [];
316
+ const themePreviews: DesktopThemePreviewState[] = [];
317
+ const bridge = createDesktopBridge("main", { mainSnapshots, themePreviews });
318
+
319
+ testSetup = await testRender(
320
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
321
+ <DispatchCapture />
322
+ <ThemeSelectorHarness />
323
+ </AppProvider>,
324
+ { width: 32, height: 4 },
325
+ );
326
+
327
+ await testSetup.renderOnce();
328
+ mainSnapshots.length = 0;
329
+ themePreviews.length = 0;
330
+
331
+ await act(() => {
332
+ capturedDispatch?.({ type: "SET_THEME", theme: "green" });
333
+ });
334
+
335
+ await testSetup.renderOnce();
336
+ await testSetup.renderOnce();
337
+ expect(mainSnapshots).toHaveLength(1);
338
+ expect(mainSnapshots[0]?.config.theme).toBe("green");
339
+ expect(themePreviews).toHaveLength(0);
340
+ });
341
+
342
+ test("desktop detached windows apply theme preview messages locally", async () => {
343
+ let previewListener: ((preview: DesktopThemePreviewState) => void) | null = null;
344
+ const bridge = createDesktopBridge("detached", {
345
+ onThemePreviewSubscribe: (listener) => {
346
+ previewListener = listener;
347
+ },
348
+ });
349
+
350
+ testSetup = await testRender(
351
+ <AppProvider config={createTickerDetailConfig("AAPL")} desktopBridge={bridge}>
352
+ <ThemeSelectorHarness />
353
+ </AppProvider>,
354
+ { width: 32, height: 4 },
355
+ );
356
+
357
+ await testSetup.renderOnce();
358
+ expect(previewListener).not.toBeNull();
359
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:amber`);
360
+
361
+ await act(() => {
362
+ previewListener?.({ theme: "green" });
363
+ });
364
+
365
+ await testSetup.renderOnce();
366
+ await testSetup.renderOnce();
367
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
368
+ });
184
369
  });
@@ -111,6 +111,38 @@ describe("resolveTickerForPane", () => {
111
111
  )?.zIndex).toBe(21);
112
112
  });
113
113
 
114
+ test("raises the session-focused floating pane above tied saved z-order", () => {
115
+ const config = createDefaultConfig("/tmp/gloomberb-test");
116
+ config.layout = {
117
+ ...config.layout,
118
+ dockRoot: null,
119
+ floating: [
120
+ { instanceId: "ticker-detail:main", x: 4, y: 2, width: 60, height: 22, zIndex: 50 },
121
+ { instanceId: "chat:main", x: 8, y: 4, width: 48, height: 18, zIndex: 50 },
122
+ ],
123
+ };
124
+ config.layouts = config.layouts.map((entry, index) => (
125
+ index === config.activeLayoutIndex ? { ...entry, layout: cloneLayout(config.layout) } : entry
126
+ ));
127
+ const sessionSnapshot: AppSessionSnapshot = {
128
+ paneState: {},
129
+ focusedPaneId: "ticker-detail:main",
130
+ activePanel: "right",
131
+ statusBarVisible: true,
132
+ openPaneIds: ["ticker-detail:main", "chat:main"],
133
+ hydrationTargets: [],
134
+ exchangeCurrencies: ["USD"],
135
+ savedAt: Date.now(),
136
+ };
137
+
138
+ const state = createInitialState(config, sessionSnapshot);
139
+ const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
140
+ const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
141
+
142
+ expect(tickerEntry?.zIndex).toBe(51);
143
+ expect((tickerEntry?.zIndex ?? 0) > (chatEntry?.zIndex ?? 0)).toBe(true);
144
+ });
145
+
114
146
  test("raises the desktop-snapshot focused floating pane during hydration", () => {
115
147
  const config = createDefaultConfig("/tmp/gloomberb-test");
116
148
  config.layout = {
@@ -455,6 +487,39 @@ describe("quote merging", () => {
455
487
  });
456
488
 
457
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
+
510
+ test("cycles backward from a stale focused pane to the last visible pane", () => {
511
+ const config = createDefaultConfig("/tmp/gloomberb-test");
512
+ const state = createInitialState(config);
513
+ state.focusedPaneId = "hidden:stale";
514
+
515
+ const next = appReducer(state, {
516
+ type: "FOCUS_PREV",
517
+ paneOrder: ["portfolio-list:main", "ticker-detail:main", "ticker-detail:nvda"],
518
+ });
519
+
520
+ expect(next.focusedPaneId).toBe("ticker-detail:nvda");
521
+ });
522
+
458
523
  test("focuses the highest remaining floating pane when the focused floating pane closes", () => {
459
524
  const config = createDefaultConfig("/tmp/gloomberb-test");
460
525
  const backgroundPane = createPaneInstance("chat", {
@@ -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
  }
@@ -0,0 +1,73 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "../renderers/opentui/test-utils";
4
+ import { setSharedMarketDataCoordinator } from "../market-data/coordinator";
5
+ import { setSharedRegistryForTests, type PluginRegistry } from "../plugins/registry";
6
+ import { createDefaultConfig } from "../types/config";
7
+ import { AppContext, createInitialState } from "./app-context";
8
+ import { useInlineTickers } from "./use-inline-tickers";
9
+
10
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
11
+
12
+ function InlineTickerHarness() {
13
+ const { catalog } = useInlineTickers(["$LGD1L"]);
14
+ return <text>{catalog.LGD1L?.status ?? "none"}</text>;
15
+ }
16
+
17
+ afterEach(() => {
18
+ testSetup?.renderer.destroy();
19
+ testSetup = undefined;
20
+ setSharedMarketDataCoordinator(null);
21
+ setSharedRegistryForTests(undefined);
22
+ });
23
+
24
+ describe("useInlineTickers", () => {
25
+ test("does not leak rejected quote lookups for existing inline tickers", async () => {
26
+ const config = createDefaultConfig("/tmp/gloomberb-inline-tickers-test");
27
+ const state = createInitialState(config);
28
+ state.tickers.set("LGD1L", {
29
+ metadata: {
30
+ ticker: "LGD1L",
31
+ exchange: "NASDAQ",
32
+ currency: "USD",
33
+ name: "Unsupported quote",
34
+ portfolios: [],
35
+ watchlists: [],
36
+ positions: [],
37
+ broker_contracts: [],
38
+ custom: {},
39
+ tags: [],
40
+ },
41
+ });
42
+ const actions: unknown[] = [];
43
+ setSharedRegistryForTests({
44
+ marketData: {
45
+ getQuote: async () => {
46
+ throw new Error("No quote provider available for LGD1L");
47
+ },
48
+ },
49
+ pinTicker: () => {},
50
+ } as unknown as PluginRegistry);
51
+
52
+ await act(async () => {
53
+ testSetup = await testRender(
54
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
55
+ <InlineTickerHarness />
56
+ </AppContext>,
57
+ { width: 20, height: 1 },
58
+ );
59
+ });
60
+
61
+ await act(async () => {
62
+ await testSetup!.renderOnce();
63
+ await Promise.resolve();
64
+ await Promise.resolve();
65
+ });
66
+ await act(async () => {
67
+ await testSetup!.renderOnce();
68
+ });
69
+
70
+ expect(testSetup.captureCharFrame()).toContain("missing");
71
+ expect(actions).toEqual([]);
72
+ });
73
+ });
@@ -18,6 +18,7 @@ export interface InlineTickerCatalogEntry {
18
18
  const resolutionInFlight = new Map<string, Promise<void>>();
19
19
  const quoteInFlight = new Map<string, Promise<void>>();
20
20
  const missingSymbols = new Set<string>();
21
+ const quoteMissingSymbols = new Set<string>();
21
22
  const initialQuoteRequested = new Set<string>();
22
23
 
23
24
  function normalizeSymbols(texts: readonly string[]): string[] {
@@ -32,7 +33,7 @@ export function useInlineTickers(texts: readonly string[]): {
32
33
  const tickers = useAppSelector((state) => state.tickers);
33
34
  const financials = useAppSelector((state) => state.financials);
34
35
  const registry = getSharedRegistry();
35
- const [, setRefreshVersion] = useState(0);
36
+ const [refreshVersion, setRefreshVersion] = useState(0);
36
37
  const textsKey = texts.join("\u0000");
37
38
  const symbols = useMemo(() => normalizeSymbols(texts), [textsKey]);
38
39
  const symbolsKey = symbols.join("|");
@@ -54,6 +55,7 @@ export function useInlineTickers(texts: readonly string[]): {
54
55
 
55
56
  if (currentTicker && currentQuote) {
56
57
  missingSymbols.delete(symbol);
58
+ quoteMissingSymbols.delete(symbol);
57
59
  initialQuoteRequested.delete(symbol);
58
60
  continue;
59
61
  }
@@ -123,8 +125,12 @@ export function useInlineTickers(texts: readonly string[]): {
123
125
  }
124
126
  : undefined,
125
127
  );
128
+ quoteMissingSymbols.delete(symbol);
126
129
  current.dispatch({ type: "MERGE_QUOTE", symbol, quote });
127
130
  })()
131
+ .catch(() => {
132
+ quoteMissingSymbols.add(symbol);
133
+ })
128
134
  .finally(() => {
129
135
  quoteInFlight.delete(symbol);
130
136
  });
@@ -162,15 +168,18 @@ export function useInlineTickers(texts: readonly string[]): {
162
168
  registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
163
169
  }, [registry]);
164
170
 
165
- const catalog: Record<string, InlineTickerCatalogEntry> = {};
166
- for (const symbol of symbols) {
167
- const ticker = tickers.get(symbol) ?? null;
168
- const quote = financials.get(symbol)?.quote ?? null;
169
- const status: InlineTickerStatus = ticker
170
- ? (quote ? "ready" : "loading")
171
- : (missingSymbols.has(symbol) ? "missing" : "loading");
172
- catalog[symbol] = { status, ticker, quote };
173
- }
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]);
174
183
 
175
184
  return { catalog, openTicker };
176
185
  }
@@ -23,8 +23,9 @@ export function createTestPluginRuntime(
23
23
  switchTab() {},
24
24
  switchPanel() {},
25
25
  openCommandBar() {},
26
- showWidget() {},
27
- hideWidget() {},
26
+ showPane() {},
27
+ hidePane() {},
28
+ openPaneSettings() {},
28
29
  openPluginCommandWorkflow() {},
29
30
  notify() {},
30
31
  subscribeResumeState: () => () => {},