gloomberb 0.11.1 → 0.11.2

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 (233) hide show
  1. package/README.md +10 -9
  2. package/README.zh-CN.md +6 -6
  3. package/package.json +1 -1
  4. package/src/api-client/data.ts +7 -0
  5. package/src/api-client/index.test.ts +47 -0
  6. package/src/api-client/index.ts +61 -0
  7. package/src/api-client/paths.ts +16 -0
  8. package/src/api-client/types.ts +35 -0
  9. package/src/app/pane-runtime/index.tsx +4 -1
  10. package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
  11. package/src/app/pane-runtime/plugin-bindings.ts +25 -4
  12. package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
  13. package/src/app/runtime/desktop-deeplink.test.ts +16 -0
  14. package/src/app/runtime/desktop-deeplink.ts +66 -2
  15. package/src/app/runtime/desktop-menu.ts +3 -0
  16. package/src/app/runtime/plugin-bindings.ts +30 -28
  17. package/src/app.tsx +9 -2
  18. package/src/cli/commands/automation.test.ts +9 -0
  19. package/src/cli/commands/automation.ts +22 -73
  20. package/src/cli/index.ts +0 -2
  21. package/src/components/chart/composite/composite-chart.test.tsx +45 -2
  22. package/src/components/chart/composite/composite-chart.tsx +12 -9
  23. package/src/components/chart/composite/scene.ts +0 -21
  24. package/src/components/chart/composite/tools.ts +49 -0
  25. package/src/components/command-bar/commands/direct/index.ts +4 -0
  26. package/src/components/command-bar/commands/registry.ts +8 -1
  27. package/src/components/command-bar/layout-items/current-layout.ts +1 -1
  28. package/src/components/command-bar/pane-templates/workflow.ts +4 -0
  29. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
  30. package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
  31. package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
  32. package/src/components/command-bar/surface/index.test.tsx +6 -6
  33. package/src/components/command-bar/surface/pane.test.tsx +66 -2
  34. package/src/components/command-bar/view-model.test.ts +2 -1
  35. package/src/components/command-bar/workflow/ops.test.ts +54 -0
  36. package/src/components/command-bar/workflow/ops.ts +1 -1
  37. package/src/components/layout/detached-pane-shell.tsx +74 -10
  38. package/src/components/layout/pane/title.ts +31 -3
  39. package/src/components/layout/shell/active-drag.ts +4 -11
  40. package/src/components/layout/shell/drag/index.ts +4 -20
  41. package/src/components/layout/shell/drag/runtime.ts +3 -4
  42. package/src/components/layout/shell/index.test.tsx +17 -3
  43. package/src/components/layout/shell/index.tsx +86 -30
  44. package/src/components/layout/shell/menu.test.ts +37 -1
  45. package/src/components/layout/shell/menu.ts +14 -0
  46. package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
  47. package/src/components/layout/shell/pane/actions.ts +0 -5
  48. package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
  49. package/src/components/layout/shell/shortcuts.ts +6 -3
  50. package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
  51. package/src/components/layout/shell/ticker-link-menu.ts +78 -0
  52. package/src/components/layout/status-bar.test.tsx +62 -61
  53. package/src/components/layout/status-bar.tsx +53 -75
  54. package/src/core/state/app/layout-reducer.ts +36 -0
  55. package/src/core/state/app/layout.ts +10 -5
  56. package/src/core/state/app/state.ts +0 -13
  57. package/src/core/state/app/types.ts +6 -4
  58. package/src/data/config/chart-settings.ts +1 -1
  59. package/src/data/config/layout.ts +2 -0
  60. package/src/data/config/store/index.test.ts +27 -6
  61. package/src/data/config/store/normalize.ts +3 -5
  62. package/src/data/plugin-state-store.ts +11 -0
  63. package/src/data/sqlite/cache.test.ts +10 -0
  64. package/src/i18n/es.ts +3 -4
  65. package/src/i18n/ja.ts +3 -4
  66. package/src/i18n/ko.ts +3 -4
  67. package/src/i18n/zh-cn.ts +3 -4
  68. package/src/i18n/zh-tw.ts +3 -4
  69. package/src/layout-marketplace/api.test.ts +54 -0
  70. package/src/layout-marketplace/api.ts +65 -0
  71. package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
  72. package/src/layout-marketplace/gallery-desktop.tsx +375 -0
  73. package/src/layout-marketplace/gallery-terminal.tsx +376 -0
  74. package/src/layout-marketplace/gallery.test.tsx +84 -0
  75. package/src/layout-marketplace/gallery.tsx +323 -0
  76. package/src/layout-marketplace/geometry.test.ts +47 -0
  77. package/src/layout-marketplace/geometry.ts +80 -0
  78. package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
  79. package/src/layout-marketplace/mini-workspace.tsx +428 -0
  80. package/src/layout-marketplace/model.test.ts +118 -0
  81. package/src/layout-marketplace/model.ts +171 -0
  82. package/src/layout-marketplace/name-dialog.tsx +60 -0
  83. package/src/layout-marketplace/pane-imagery.ts +85 -0
  84. package/src/layout-marketplace/pane.tsx +20 -0
  85. package/src/layout-marketplace/payload.test.ts +246 -0
  86. package/src/layout-marketplace/payload.ts +624 -0
  87. package/src/layout-marketplace/use-marketplace.ts +63 -0
  88. package/src/market-data/hooks.tsx +0 -41
  89. package/src/plugins/broker-sync/broker-sync.test.ts +29 -0
  90. package/src/plugins/broker-sync/normalize.ts +9 -2
  91. package/src/plugins/builtin/ai/index.tsx +18 -2
  92. package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
  93. package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
  94. package/src/plugins/builtin/alerts/alert-engine.test.ts +16 -1
  95. package/src/plugins/builtin/alerts/alert-engine.ts +4 -0
  96. package/src/plugins/builtin/alerts/index.tsx +4 -0
  97. package/src/plugins/builtin/alerts/pane.tsx +2 -8
  98. package/src/plugins/builtin/analytics/index.tsx +3 -0
  99. package/src/plugins/builtin/broker-manager/index.tsx +3 -0
  100. package/src/plugins/builtin/cds/client.test.ts +83 -0
  101. package/src/plugins/builtin/cds/client.ts +67 -0
  102. package/src/plugins/builtin/cds/index.tsx +41 -0
  103. package/src/plugins/builtin/cds/model.test.ts +232 -0
  104. package/src/plugins/builtin/cds/model.ts +401 -0
  105. package/src/plugins/builtin/cds/pane.test.tsx +132 -0
  106. package/src/plugins/builtin/cds/pane.tsx +358 -0
  107. package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
  108. package/src/plugins/builtin/chart-composer/index.tsx +88 -2
  109. package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
  110. package/src/plugins/builtin/chart-composer/pane.tsx +81 -14
  111. package/src/plugins/builtin/chat/content/index.tsx +4 -1
  112. package/src/plugins/builtin/chat/controller/index.ts +8 -1
  113. package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
  114. package/src/plugins/builtin/chat/controller/session-runtime.ts +14 -2
  115. package/src/plugins/builtin/chat/controller/utils.ts +4 -6
  116. package/src/plugins/builtin/chat/controller.test.ts +64 -14
  117. package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
  118. package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
  119. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
  120. package/src/plugins/builtin/chat/profile-popover.ts +53 -22
  121. package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
  122. package/src/plugins/builtin/chat/sidebar.tsx +63 -0
  123. package/src/plugins/builtin/chat/test-harness.tsx +29 -1
  124. package/src/plugins/builtin/cloud/auth-commands.ts +0 -4
  125. package/src/plugins/builtin/cloud/auth-model.ts +4 -1
  126. package/src/plugins/builtin/cloud/plugin.tsx +11 -1
  127. package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
  128. package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
  129. package/src/plugins/builtin/cloud-tweets/registration.ts +44 -5
  130. package/src/plugins/builtin/composite-plugins.ts +3 -1
  131. package/src/plugins/builtin/connections/index.ts +3 -0
  132. package/src/plugins/builtin/econ/calendar-model.ts +0 -5
  133. package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
  134. package/src/plugins/builtin/help/index.tsx +2 -2
  135. package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
  136. package/src/plugins/builtin/layout-manager/index.tsx +17 -4
  137. package/src/plugins/builtin/news/index.tsx +1 -0
  138. package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
  139. package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
  140. package/src/plugins/builtin/news/wire/index.tsx +2 -2
  141. package/src/plugins/builtin/options/index.tsx +1 -0
  142. package/src/plugins/builtin/options/view.test.tsx +25 -3
  143. package/src/plugins/builtin/options/view.tsx +1 -0
  144. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +2 -0
  145. package/src/plugins/builtin/portfolio-list/account-metrics.ts +1 -1
  146. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  147. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
  148. package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
  149. package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
  150. package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
  151. package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
  152. package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
  153. package/src/plugins/builtin/portfolio-list/summary/index.tsx +21 -16
  154. package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
  155. package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +33 -7
  156. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +109 -12
  157. package/src/plugins/builtin/research/index.tsx +4 -0
  158. package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
  159. package/src/plugins/builtin/shared/public-pane.ts +9 -0
  160. package/src/plugins/builtin/shared/ticker-source.ts +32 -0
  161. package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
  162. package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
  163. package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
  164. package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
  165. package/src/plugins/builtin/world-indices/index.tsx +2 -0
  166. package/src/plugins/catalog-browser.test.ts +10 -0
  167. package/src/plugins/catalog-browser.ts +3 -1
  168. package/src/plugins/gridlock-notification.ts +2 -1
  169. package/src/plugins/pane-manager/gridlock.ts +87 -3
  170. package/src/plugins/pane-manager.test.ts +30 -0
  171. package/src/plugins/pane-manager.ts +6 -1
  172. package/src/plugins/prediction-markets/chart.tsx +2 -2
  173. package/src/plugins/prediction-markets/controller/index.ts +5 -1
  174. package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
  175. package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
  176. package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
  177. package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
  178. package/src/plugins/prediction-markets/detail.test.tsx +29 -2
  179. package/src/plugins/prediction-markets/index.tsx +8 -4
  180. package/src/plugins/prediction-markets/navigation.ts +0 -1
  181. package/src/plugins/prediction-markets/pane.test.tsx +77 -0
  182. package/src/plugins/prediction-markets/pane.tsx +4 -0
  183. package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
  184. package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
  185. package/src/plugins/registry/index.ts +2 -0
  186. package/src/plugins/ticker-navigation.test.ts +24 -0
  187. package/src/plugins/ticker-navigation.ts +40 -0
  188. package/src/remote/schema.ts +1 -1
  189. package/src/renderers/browser/cloud-transport.test.ts +36 -0
  190. package/src/renderers/browser/cloud-transport.ts +19 -0
  191. package/src/renderers/browser/config-host.ts +0 -4
  192. package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
  193. package/src/renderers/browser/deeplink-bridge.ts +22 -0
  194. package/src/renderers/browser/main.tsx +3 -0
  195. package/src/renderers/cloudflare/worker.test.ts +8 -5
  196. package/src/renderers/cloudflare/worker.ts +6 -5
  197. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  198. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  199. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
  201. package/src/renderers/electrobun/view/host/native.ts +0 -5
  202. package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
  203. package/src/renderers/electrobun/view/styles.css +107 -7
  204. package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
  205. package/src/renderers/electrobun/view/toast-host.tsx +69 -43
  206. package/src/renderers/electrobun/view/ui-host.tsx +4 -1
  207. package/src/renderers/share/layout-view.test.tsx +58 -0
  208. package/src/renderers/share/layout-view.tsx +133 -0
  209. package/src/renderers/share/main.tsx +54 -6
  210. package/src/renderers/share/styles.css +35 -0
  211. package/src/renderers/share/view.test.tsx +78 -0
  212. package/src/renderers/share/view.tsx +71 -0
  213. package/src/shares/api.test.ts +52 -3
  214. package/src/shares/api.ts +39 -15
  215. package/src/shares/location.test.ts +19 -0
  216. package/src/shares/location.ts +11 -0
  217. package/src/shares/pane.test.ts +110 -0
  218. package/src/shares/pane.ts +54 -0
  219. package/src/shares/payload.ts +74 -1
  220. package/src/state/app/bootstrap.ts +6 -7
  221. package/src/state/app/context/index.test.ts +41 -1
  222. package/src/state/app/context/index.test.tsx +34 -16
  223. package/src/state/selectors-ui.ts +0 -8
  224. package/src/sync/controller.test.ts +57 -1
  225. package/src/sync/controller.ts +22 -11
  226. package/src/sync/react.ts +13 -0
  227. package/src/theme/font-scale.ts +0 -11
  228. package/src/types/config.ts +35 -5
  229. package/src/types/desktop-menu.ts +1 -0
  230. package/src/types/plugin.ts +36 -0
  231. package/src/ui/context-menu.tsx +1 -1
  232. package/src/ui/toast.tsx +2 -0
  233. package/src/utils/back-navigation.ts +0 -4
@@ -1,3 +1,4 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { DataTableView, type DataTableColumn } from "../../../components";
2
3
  import { colors } from "../../../theme/colors";
3
4
  import { formatNumber } from "../../../utils/format";
@@ -24,20 +25,44 @@ export function PredictionMarketTradesView({
24
25
  trades: PredictionTrade[];
25
26
  width: number;
26
27
  }) {
28
+ const visibleTrades = trades.slice(0, 30);
29
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(() =>
30
+ visibleTrades.length > 0 ? 0 : null,
31
+ );
32
+
33
+ useEffect(() => {
34
+ setSelectedIndex((current) => {
35
+ if (visibleTrades.length === 0) return null;
36
+ if (current == null || current >= visibleTrades.length) return 0;
37
+ return current;
38
+ });
39
+ }, [visibleTrades.length]);
40
+
27
41
  return (
28
42
  <DataTableView<PredictionTrade, TradeColumn>
29
43
  focused={focused}
30
- keyboardNavigation={false}
44
+ keyboardNavigation={focused}
31
45
  rootWidth={width}
32
46
  rootBackgroundColor={colors.panel}
33
- selection={{ kind: "none" }}
47
+ selection={{
48
+ kind: "index",
49
+ selectedIndex,
50
+ onChange: (index) => setSelectedIndex(index),
51
+ }}
34
52
  columns={TRADE_COLUMNS}
35
- items={trades.slice(0, 30)}
53
+ items={visibleTrades}
36
54
  sortColumnId={null}
37
55
  sortDirection="asc"
38
56
  onHeaderClick={() => {}}
39
57
  getItemKey={(trade) => trade.id}
40
- renderCell={(trade, column) => {
58
+ onRowMouseDown={(_trade, index, event) => {
59
+ event.preventDefault();
60
+ setSelectedIndex(index);
61
+ return true;
62
+ }}
63
+ renderCell={(trade, column, _index, rowState) => {
64
+ const color = (fallback: string) =>
65
+ rowState.selected ? undefined : fallback;
41
66
  const tradeColor = trade.side === "buy" ? colors.positive : colors.negative;
42
67
  switch (column.id) {
43
68
  case "time":
@@ -45,27 +70,27 @@ export function PredictionMarketTradesView({
45
70
  text: new Date(trade.timestamp).toLocaleTimeString("en-US", {
46
71
  hour12: false,
47
72
  }),
48
- color: colors.textDim,
73
+ color: color(colors.textDim),
49
74
  };
50
75
  case "side":
51
76
  return {
52
77
  text: trade.side.toUpperCase(),
53
- color: tradeColor,
78
+ color: color(tradeColor),
54
79
  };
55
80
  case "outcome":
56
81
  return {
57
82
  text: trade.outcome.toUpperCase(),
58
- color: colors.text,
83
+ color: color(colors.text),
59
84
  };
60
85
  case "price":
61
86
  return {
62
87
  text: formatPredictionProbability(trade.price),
63
- color: tradeColor,
88
+ color: color(tradeColor),
64
89
  };
65
90
  case "size":
66
91
  return {
67
92
  text: formatNumber(trade.size, 0),
68
- color: colors.textDim,
93
+ color: color(colors.textDim),
69
94
  };
70
95
  }
71
96
  }}
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
2
3
  import { testRender } from "../../renderers/opentui/test-utils";
3
4
  import {
4
5
  ChartHarness,
@@ -32,7 +33,7 @@ describe("prediction markets detail views", () => {
32
33
  expect(frame).not.toContain("TypeError");
33
34
  });
34
35
 
35
- test("renders grouped selections as ranked outcomes in the detail overview", async () => {
36
+ test("renders grouped outcomes with their chart directly below", async () => {
36
37
  testSetup = await testRender(<GroupedDetailHarness />, {
37
38
  width: 64,
38
39
  height: 24,
@@ -43,6 +44,9 @@ describe("prediction markets detail views", () => {
43
44
  expect(frame).toContain("Outcomes");
44
45
  expect(frame).toContain("Above 4.25%");
45
46
  expect(frame).toContain("Above 4.50%");
47
+ expect(frame).toContain("1M");
48
+ expect(frame.indexOf("1M")).toBeGreaterThan(frame.indexOf("Above 4.50%"));
49
+ expect(frame).not.toContain(" Chart ");
46
50
  expect(frame).not.toContain("Ranked by implied YES probability.");
47
51
  expect(frame).not.toContain("TOP Above 4.25%");
48
52
  expect(frame).not.toContain("Kalshi");
@@ -59,9 +63,32 @@ describe("prediction markets detail views", () => {
59
63
  const frame = testSetup.captureCharFrame();
60
64
  expect(frame).toContain("Outcomes");
61
65
  expect(frame).toContain("Above 4.25%");
62
- expect(frame).toContain("Chart");
66
+ expect(frame).toContain("Loading chart...");
63
67
  expect(frame).not.toContain("No chart history.");
64
68
  expect(frame).not.toContain("Loading market detail...");
65
69
  });
66
70
 
71
+ test("shows the chart crosshair on pointer movement", async () => {
72
+ testSetup = await testRender(
73
+ <ChartHarness
74
+ history={[
75
+ { date: "2026-04-01T00:00:00Z", close: 0.45 },
76
+ { date: "2026-04-02T00:00:00Z", close: 0.48 },
77
+ { date: "2026-04-03T00:00:00Z", close: 0.51 },
78
+ ]}
79
+ />,
80
+ { width: 80, height: 12 },
81
+ );
82
+ await flushFrames(testSetup);
83
+ const initialFrame = testSetup.captureCharFrame();
84
+
85
+ await act(async () => {
86
+ await testSetup!.mockMouse.moveTo(30, 5);
87
+ await testSetup!.renderOnce();
88
+ });
89
+ await flushFrames(testSetup);
90
+
91
+ expect(testSetup.captureCharFrame()).not.toBe(initialFrame);
92
+ });
93
+
67
94
  });
@@ -1,4 +1,5 @@
1
1
  import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
2
+ import { resolvePaneInstance } from "../../types/config";
2
3
  import { parsePredictionSearchShortcut } from "./navigation";
3
4
  import { PredictionMarketsPane } from "./pane";
4
5
  import { attachPredictionMarketsPersistence, resetPredictionMarketsPersistence } from "./services/fetch";
@@ -15,10 +16,13 @@ const MAIN_INSTANCE_ID = `${PANE_ID}:main`;
15
16
 
16
17
  function openPredictionMarkets(ctx: GloomPluginContext, query = ""): void {
17
18
  const parsed = parsePredictionSearchShortcut(query);
18
- ctx.resume.setPaneState(MAIN_INSTANCE_ID, "venueScope", parsed.venueScope);
19
- ctx.resume.setPaneState(MAIN_INSTANCE_ID, "searchQuery", parsed.searchQuery);
20
- ctx.resume.setPaneState(MAIN_INSTANCE_ID, "selectedMarketKey", null);
21
- ctx.focusPane(PANE_ID);
19
+ const existingInstanceId = resolvePaneInstance(ctx.getConfig().layout, PANE_ID)?.instanceId;
20
+ const targetInstanceId = existingInstanceId ?? MAIN_INSTANCE_ID;
21
+ ctx.resume.setPaneState(targetInstanceId, "venueScope", parsed.venueScope);
22
+ ctx.resume.setPaneState(targetInstanceId, "searchQuery", parsed.searchQuery);
23
+ ctx.resume.setPaneState(targetInstanceId, "selectedRowKey", null);
24
+ ctx.resume.setPaneState(targetInstanceId, "selectedDetailMarketKey", null);
25
+ ctx.focusPane(existingInstanceId ?? PANE_ID);
22
26
  }
23
27
 
24
28
  export const predictionMarketsPlugin: GloomPlugin = {
@@ -28,7 +28,6 @@ export const DETAIL_TABS: ReadonlyArray<{
28
28
  value: PredictionDetailTab;
29
29
  }> = [
30
30
  { label: "Overview", value: "overview" },
31
- { label: "Chart", value: "chart" },
32
31
  { label: "Book", value: "book" },
33
32
  { label: "Trades", value: "trades" },
34
33
  { label: "Rules", value: "rules" },
@@ -340,6 +340,83 @@ describe("prediction markets pane interactions", () => {
340
340
  expect(frame).toContain("/ search markets");
341
341
  });
342
342
 
343
+ test("navigates the book and trades tables with arrow keys", async () => {
344
+ installPredictionMarketMocks();
345
+
346
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
347
+ await flushFrames(testSetup);
348
+ await emitKeypress(testSetup, { name: "j", sequence: "j" });
349
+ await emitKeypress(testSetup, { name: "enter", sequence: "\r" });
350
+ await flushFrames(testSetup);
351
+
352
+ let rows = testSetup.captureCharFrame().split("\n");
353
+ const detailTabsRow = rows.findIndex((line) =>
354
+ line.includes("Overview") && line.includes("Book") && line.includes("Trades"),
355
+ );
356
+ const bookColumn = rows[detailTabsRow]!.indexOf("Book");
357
+ await act(async () => {
358
+ await testSetup!.mockMouse.click(bookColumn, detailTabsRow);
359
+ await testSetup!.renderOnce();
360
+ });
361
+ await flushFrames(testSetup);
362
+
363
+ rows = testSetup.captureCharFrame().split("\n");
364
+ let headerRow = rows.findIndex((line) =>
365
+ line.includes("OUT") && line.includes("SIDE") && line.includes("PRICE"),
366
+ );
367
+ expect(headerRow).toBeGreaterThanOrEqual(0);
368
+ let spans = testSetup.captureSpans().lines;
369
+ const bookFirstBefore = spans[headerRow + 1]!.spans.find(
370
+ (span) => span.text.trim(),
371
+ )!.bg;
372
+ const bookSecondBefore = spans[headerRow + 2]!.spans.find(
373
+ (span) => span.text.trim(),
374
+ )!.bg;
375
+ expect(bookFirstBefore).not.toEqual(bookSecondBefore);
376
+
377
+ await emitKeypress(testSetup, { name: "down", sequence: "\u001b[B" });
378
+ await flushFrames(testSetup);
379
+ spans = testSetup.captureSpans().lines;
380
+ expect(
381
+ spans[headerRow + 1]!.spans.find((span) => span.text.trim())!.bg,
382
+ ).toEqual(bookSecondBefore);
383
+ expect(
384
+ spans[headerRow + 2]!.spans.find((span) => span.text.trim())!.bg,
385
+ ).toEqual(bookFirstBefore);
386
+
387
+ rows = testSetup.captureCharFrame().split("\n");
388
+ const tradesColumn = rows[detailTabsRow]!.indexOf("Trades");
389
+ await act(async () => {
390
+ await testSetup!.mockMouse.click(tradesColumn, detailTabsRow);
391
+ await testSetup!.renderOnce();
392
+ });
393
+ await flushFrames(testSetup);
394
+
395
+ rows = testSetup.captureCharFrame().split("\n");
396
+ headerRow = rows.findIndex((line) =>
397
+ line.includes("TIME") && line.includes("SIDE") && line.includes("PRICE"),
398
+ );
399
+ expect(headerRow).toBeGreaterThanOrEqual(0);
400
+ spans = testSetup.captureSpans().lines;
401
+ const tradeFirstBefore = spans[headerRow + 1]!.spans.find(
402
+ (span) => span.text.trim(),
403
+ )!.bg;
404
+ const tradeSecondBefore = spans[headerRow + 2]!.spans.find(
405
+ (span) => span.text.trim(),
406
+ )!.bg;
407
+ expect(tradeFirstBefore).not.toEqual(tradeSecondBefore);
408
+
409
+ await emitKeypress(testSetup, { name: "down", sequence: "\u001b[B" });
410
+ await flushFrames(testSetup);
411
+ spans = testSetup.captureSpans().lines;
412
+ expect(
413
+ spans[headerRow + 1]!.spans.find((span) => span.text.trim())!.bg,
414
+ ).toEqual(tradeSecondBefore);
415
+ expect(
416
+ spans[headerRow + 2]!.spans.find((span) => span.text.trim())!.bg,
417
+ ).toEqual(tradeFirstBefore);
418
+ });
419
+
343
420
  test("supports detail outcome navigation and escape return from the keyboard", async () => {
344
421
  attachPredictionMarketsPersistence(new MemoryPersistence());
345
422
 
@@ -262,9 +262,13 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
262
262
  <Box
263
263
  flexDirection="column"
264
264
  flexGrow={1}
265
+ flexShrink={1}
266
+ flexBasis={0}
267
+ minHeight={0}
265
268
  width={width}
266
269
  height={Math.max(height - 1, 1)}
267
270
  paddingX={1}
271
+ overflow="hidden"
268
272
  backgroundColor={colors.panel}
269
273
  >
270
274
  <PredictionMarketDetailPane
@@ -1,4 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createDefaultConfig } from "../../types/config";
3
+ import type { CommandDef } from "../../types/plugin";
2
4
  import {
3
5
  cleanupPredictionTest,
4
6
  installPredictionMarketMocks,
@@ -23,12 +25,46 @@ import {
23
25
  loadPolymarketDetail,
24
26
  normalizePolymarketMarket,
25
27
  } from "./services/polymarket/adapter";
28
+ import { predictionMarketsPlugin } from "./index";
26
29
 
27
30
  afterEach(async () => {
28
31
  await cleanupPredictionTest();
29
32
  });
30
33
 
31
34
  describe("prediction markets plugin registration and services", () => {
35
+ test("routes search state to a materialized pane instance", async () => {
36
+ const config = createDefaultConfig("/tmp/gloomberb-prediction-command-test");
37
+ const instanceId = "prediction-markets:shared-test";
38
+ config.layout.instances.push({ instanceId, paneId: "prediction-markets" });
39
+ const commands: CommandDef[] = [];
40
+ const paneStateUpdates: Array<{ paneId: string; key: string; value: unknown }> = [];
41
+ const focused: string[] = [];
42
+
43
+ predictionMarketsPlugin.setup?.({
44
+ persistence: new MemoryPersistence(),
45
+ getConfig: () => config,
46
+ resume: {
47
+ setPaneState(paneId: string, key: string, value: unknown) {
48
+ paneStateUpdates.push({ paneId, key, value });
49
+ },
50
+ },
51
+ registerCommand: (command: CommandDef) => commands.push(command),
52
+ focusPane: (paneId: string) => focused.push(paneId),
53
+ } as any);
54
+
55
+ await commands.find((command) => command.id === "prediction-markets-search")?.execute({
56
+ query: "polymarket:fed",
57
+ });
58
+
59
+ expect(paneStateUpdates).toEqual([
60
+ { paneId: instanceId, key: "venueScope", value: "polymarket" },
61
+ { paneId: instanceId, key: "searchQuery", value: "fed" },
62
+ { paneId: instanceId, key: "selectedRowKey", value: null },
63
+ { paneId: instanceId, key: "selectedDetailMarketKey", value: null },
64
+ ]);
65
+ expect(focused).toEqual([instanceId]);
66
+ });
67
+
32
68
  test("uses fresh prediction resource cache without refetching", async () => {
33
69
  const persistence = new MemoryPersistence();
34
70
  attachPredictionMarketsPersistence(persistence);
@@ -274,6 +274,15 @@ export function installPredictionMarketMocks() {
274
274
  count_fp: "50",
275
275
  created_time: "2026-04-01T00:00:00Z",
276
276
  },
277
+ {
278
+ trade_id: "kal-2",
279
+ ticker: "KAL-1",
280
+ taker_side: "no",
281
+ yes_price_dollars: "0.46",
282
+ no_price_dollars: "0.54",
283
+ count_fp: "40",
284
+ created_time: "2026-04-01T00:01:00Z",
285
+ },
277
286
  ],
278
287
  }),
279
288
  { status: 200 },
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { AppPersistencePort, AppTickerRepositoryPort } from "../../core/app-service-ports";
3
+ import type { LayoutMarketplacePayload } from "../../layout-marketplace/payload";
3
4
  import {
4
5
  connectionHealth as sharedConnectionHealth,
5
6
  type ConnectionHealthRegistry,
@@ -129,6 +130,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
129
130
  showPaneFn: ((paneId: string) => void) = () => {};
130
131
  createPaneFromTemplateFn: ((templateId: string, options?: PaneTemplateCreateOptions) => void) = () => {};
131
132
  createPaneFromTemplateAsyncFn: ((templateId: string, options?: PaneTemplateCreateOptions) => Promise<void>) = async () => {};
133
+ openPortablePaneShareAsyncFn: ((layout: LayoutMarketplacePayload) => Promise<void>) = async () => {};
132
134
  hidePaneFn: ((paneId: string) => void) = () => {};
133
135
  focusPaneFn: ((paneId: string) => void) = () => {};
134
136
  pinTickerFn: ((symbol: string, options?: PinTickerOptions) => void) = () => {};
@@ -7,6 +7,8 @@ import {
7
7
  } from "../types/config";
8
8
  import {
9
9
  findFixedTickerPaneForSymbol,
10
+ findTickerResearchFollower,
11
+ resolveTickerActivation,
10
12
  resolveTickerNavigationReplacementPane,
11
13
  shouldFocusTickerNavigationTarget,
12
14
  } from "./ticker-navigation";
@@ -26,6 +28,28 @@ function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
26
28
  };
27
29
  }
28
30
 
31
+ describe("ticker source activation", () => {
32
+ test("focuses a bound follower unless the caller asks for a new pane", () => {
33
+ const layout = createLayout([
34
+ createPaneInstance("portfolio-list", {
35
+ instanceId: "portfolio-list:main",
36
+ binding: { kind: "none" },
37
+ }),
38
+ createPaneInstance("ticker-detail", {
39
+ instanceId: "ticker-detail:main",
40
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
41
+ }),
42
+ ]);
43
+
44
+ expect(findTickerResearchFollower(layout, "portfolio-list:main")?.instanceId).toBe("ticker-detail:main");
45
+ expect(resolveTickerActivation(layout, "portfolio-list:main")).toEqual({
46
+ kind: "focus",
47
+ paneId: "ticker-detail:main",
48
+ });
49
+ expect(resolveTickerActivation(layout, "portfolio-list:main", { newPane: true })).toEqual({ kind: "open" });
50
+ });
51
+ });
52
+
29
53
  describe("resolveTickerNavigationReplacementPane", () => {
30
54
  test("only replaces the Ticker Research pane that opened ticker navigation", () => {
31
55
  const layout = createLayout([
@@ -5,8 +5,48 @@ import {
5
5
  type LayoutConfig,
6
6
  type PaneInstanceConfig,
7
7
  } from "../types/config";
8
+ import type { PaneDef } from "../types/plugin";
8
9
  import { isPaneInLayout } from "./pane-manager";
9
10
 
11
+ /** Panes that publish a cursor symbol other panes can follow (`PaneDef.tickerSource`). */
12
+ export function listVisibleTickerSourcePanes(
13
+ layout: LayoutConfig,
14
+ panes: ReadonlyMap<string, PaneDef>,
15
+ ): PaneInstanceConfig[] {
16
+ return layout.instances.filter((instance) => (
17
+ panes.get(instance.paneId)?.tickerSource === true
18
+ && isPaneInLayout(layout, instance.instanceId)
19
+ ));
20
+ }
21
+
22
+ /** The visible Ticker Research pane that follows `sourceInstanceId`, if any. */
23
+ export function findTickerResearchFollower(
24
+ layout: LayoutConfig,
25
+ sourceInstanceId: string | null | undefined,
26
+ ): PaneInstanceConfig | null {
27
+ if (!sourceInstanceId) return null;
28
+ return layout.instances.find((instance) =>
29
+ instance.paneId === TICKER_RESEARCH_PANE_ID
30
+ && instance.binding?.kind === "follow"
31
+ && instance.binding.sourceInstanceId === sourceInstanceId
32
+ && isPaneInLayout(layout, instance.instanceId)
33
+ ) ?? null;
34
+ }
35
+
36
+ /**
37
+ * Enter opens the follower already bound to the source pane; Shift+Enter (`newPane`) and sources
38
+ * without a follower fall back to opening a ticker pane.
39
+ */
40
+ export function resolveTickerActivation(
41
+ layout: LayoutConfig,
42
+ sourcePaneId: string | null | undefined,
43
+ options?: { newPane?: boolean },
44
+ ): { kind: "focus"; paneId: string } | { kind: "open" } {
45
+ if (options?.newPane) return { kind: "open" };
46
+ const follower = findTickerResearchFollower(layout, sourcePaneId);
47
+ return follower ? { kind: "focus", paneId: follower.instanceId } : { kind: "open" };
48
+ }
49
+
10
50
  export function resolveTickerNavigationReplacementPane(
11
51
  layout: LayoutConfig,
12
52
  sourcePaneId: string | null,
@@ -43,7 +43,7 @@ export const REMOTE_OPERATIONS: RemoteOperationSchema[] = [
43
43
  op("layout.delete", "Delete a layout.", "{ index: number }", "local-write"),
44
44
  op("layout.undo", "Undo last layout change.", "{}", "local-write"),
45
45
  op("layout.redo", "Redo last layout change.", "{}", "local-write"),
46
- op("layout.gridlock", "Gridlock all visible panes into a dense layout.", "{}", "local-write"),
46
+ op("layout.gridlock", "Tidy all panes into a dense layout.", "{}", "local-write"),
47
47
  op("layout.closeFloating", "Close all floating panes in the active layout.", "{}", "local-write"),
48
48
  op("layout.placePane", "Move a pane to a layout region.", "{ paneId: string, region: 'left' | 'right' | 'top' | 'bottom' | 'floating', relativeTo?: string }", "local-write"),
49
49
  op("layout.focusRegion", "Focus a pane by visual layout region.", "{ region: 'left' | 'right' | 'top' | 'bottom' | 'center' }", "local-write"),
@@ -24,6 +24,42 @@ test("browser cloud transport uses host cookies without forwarding forbidden hea
24
24
  expect(headers.get("Accept")).toBe("application/json");
25
25
  });
26
26
 
27
+ test("browser cloud transport plants session cookies before dropping the Cookie header", async () => {
28
+ const planted: string[] = [];
29
+ const previousDocument = (globalThis as { document?: unknown }).document;
30
+ (globalThis as { document?: { cookie: string } }).document = {
31
+ get cookie() {
32
+ return planted.join("; ");
33
+ },
34
+ set cookie(value: string) {
35
+ planted.push(value);
36
+ },
37
+ };
38
+ const previousLocation = (globalThis as { location?: unknown }).location;
39
+ (globalThis as { location?: { protocol: string } }).location = { protocol: "https:" };
40
+
41
+ globalThis.fetch = (async () => {
42
+ return new Response("{}", { headers: { "content-type": "application/json" } });
43
+ }) as typeof fetch;
44
+
45
+ try {
46
+ await browserCredentialedFetch("https://api.gloom.sh/auth/get-session", {
47
+ headers: {
48
+ Cookie: "__Secure-gloomberb.session_token=signed-token.value; gloomberb.session_token=signed-token.value",
49
+ },
50
+ });
51
+ expect(planted).toEqual([
52
+ "__Secure-gloomberb.session_token=signed-token.value; Path=/; SameSite=Lax; Secure",
53
+ "gloomberb.session_token=signed-token.value; Path=/; SameSite=Lax; Secure",
54
+ ]);
55
+ } finally {
56
+ if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
57
+ else (globalThis as { document?: unknown }).document = previousDocument;
58
+ if (previousLocation === undefined) delete (globalThis as { location?: unknown }).location;
59
+ else (globalThis as { location?: unknown }).location = previousLocation;
60
+ }
61
+ });
62
+
27
63
  test("browser boot restores an existing Gloom Cloud cookie session", async () => {
28
64
  const getSession = spyOn(apiClient, "getSession").mockResolvedValue(null);
29
65
  await restoreBrowserCloudSession();
@@ -1,8 +1,27 @@
1
1
  import { apiClient, setCloudApiFetchTransport } from "../../api-client";
2
2
  import { setHttpFetchTransport } from "../../utils/http-transport";
3
3
 
4
+ const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
5
+
6
+ function plantBrowserSessionCookies(cookieHeader: string): void {
7
+ if (typeof document === "undefined") return;
8
+ const secure = typeof location !== "undefined" && location.protocol === "https:";
9
+ for (const part of cookieHeader.split(";")) {
10
+ const trimmed = part.trim();
11
+ const eq = trimmed.indexOf("=");
12
+ if (eq <= 0) continue;
13
+ const name = trimmed.slice(0, eq).trim();
14
+ const value = trimmed.slice(eq + 1);
15
+ if (!(SESSION_COOKIE_NAMES as readonly string[]).includes(name) || !value) continue;
16
+ const useSecure = secure || name.startsWith("__Secure-");
17
+ document.cookie = `${name}=${value}; Path=/; SameSite=Lax${useSecure ? "; Secure" : ""}`;
18
+ }
19
+ }
20
+
4
21
  export function browserCredentialedFetch(url: string, init: RequestInit = {}): Promise<Response> {
5
22
  const headers = new Headers(init.headers);
23
+ const cookieHeader = headers.get("Cookie");
24
+ if (cookieHeader) plantBrowserSessionCookies(cookieHeader);
6
25
  // These are controlled by the browser. Desktop transports may set them, but
7
26
  // carrying them into fetch would either fail or misrepresent the web origin.
8
27
  headers.delete("Cookie");
@@ -56,7 +56,3 @@ export function createBrowserConfigStore(storage: StorageLike): ConfigStoreHost
56
56
  export function installBrowserConfigStore(storage: StorageLike = localStorage): void {
57
57
  setConfigStoreHost(createBrowserConfigStore(storage));
58
58
  }
59
-
60
- export async function loadBrowserConfig(store = createBrowserConfigStore(localStorage)): Promise<AppConfig> {
61
- return store.loadConfig(BROWSER_DATA_DIR);
62
- }
@@ -0,0 +1,41 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createBrowserDeepLinkBridge } from "./deeplink-bridge";
3
+
4
+ const originalWindow = globalThis.window;
5
+
6
+ afterEach(() => {
7
+ if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
8
+ else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
9
+ });
10
+
11
+ describe("browser social share handoff", () => {
12
+ test("maps a valid pane share query to the common deep-link runtime", () => {
13
+ const id = "0123456789abcdef0123456789abcdef";
14
+ Object.defineProperty(globalThis, "window", {
15
+ configurable: true,
16
+ value: {
17
+ location: { search: `?share=${id}` },
18
+ addEventListener() {},
19
+ removeEventListener() {},
20
+ },
21
+ });
22
+ const seen: string[] = [];
23
+ createBrowserDeepLinkBridge().subscribe((deeplink) => seen.push(deeplink.url));
24
+ expect(seen).toEqual([`gloomberb://share/${id}`]);
25
+ });
26
+
27
+ test("prefers a shared layout query", () => {
28
+ const id = "fedcba9876543210fedcba9876543210";
29
+ Object.defineProperty(globalThis, "window", {
30
+ configurable: true,
31
+ value: {
32
+ location: { search: `?layout=${id}&share=0123456789abcdef0123456789abcdef` },
33
+ addEventListener() {},
34
+ removeEventListener() {},
35
+ },
36
+ });
37
+ const seen: string[] = [];
38
+ createBrowserDeepLinkBridge().subscribe((deeplink) => seen.push(deeplink.url));
39
+ expect(seen).toEqual([`gloomberb://layout/${id}`]);
40
+ });
41
+ });
@@ -0,0 +1,22 @@
1
+ import { marketplaceLayoutIdFromSearch } from "../../layout-marketplace/api";
2
+ import { paneShareIdFromSearch } from "../../shares/location";
3
+ import type { DesktopDeepLinkBridge } from "../../types/desktop-deeplink";
4
+
5
+ export function createBrowserDeepLinkBridge(): DesktopDeepLinkBridge {
6
+ return {
7
+ subscribe(listener) {
8
+ const emit = () => {
9
+ const layoutId = marketplaceLayoutIdFromSearch(window.location.search);
10
+ if (layoutId) {
11
+ listener({ url: `gloomberb://layout/${layoutId}` });
12
+ return;
13
+ }
14
+ const shareId = paneShareIdFromSearch(window.location.search);
15
+ if (shareId) listener({ url: `gloomberb://share/${shareId}` });
16
+ };
17
+ emit();
18
+ window.addEventListener("popstate", emit);
19
+ return () => window.removeEventListener("popstate", emit);
20
+ },
21
+ };
22
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from "./cloud-transport";
20
20
  import { BROWSER_DATA_DIR, installBrowserConfigStore } from "./config-host";
21
21
  import { browserRendererHost, browserUiHost } from "./ui-host";
22
+ import { createBrowserDeepLinkBridge } from "./deeplink-bridge";
22
23
 
23
24
  const rootElement = document.getElementById("root");
24
25
  if (!rootElement) throw new Error("Missing root element");
@@ -34,6 +35,7 @@ async function boot(): Promise<void> {
34
35
  await restoreBrowserCloudSession();
35
36
  const config = await loadConfig(BROWSER_DATA_DIR);
36
37
  applyLanguageFromConfig(config);
38
+ const deepLinkBridge = createBrowserDeepLinkBridge();
37
39
  root.render(
38
40
  <BrowserErrorBoundary>
39
41
  <UiHostProvider ui={browserUiHost} renderer={browserRendererHost} nativeRenderer={webNativeRenderer}>
@@ -44,6 +46,7 @@ async function boot(): Promise<void> {
44
46
  config={config}
45
47
  servicesFactory={createBrowserAppServices}
46
48
  plugins={getBrowserBuiltinPlugins()}
49
+ desktopDeepLinkBridge={deepLinkBridge}
47
50
  updatesEnabled={false}
48
51
  />
49
52
  </WebDialogHostProvider>
@@ -34,11 +34,14 @@ describe("static Cloudflare host", () => {
34
34
  expect(response.headers.get("cache-control")).toBe("no-store");
35
35
  });
36
36
 
37
- test("rewrites only valid public share paths to the slim document", async () => {
38
- const { env, requests } = fixture();
39
- const response = await handleRequest(new Request("https://term.example/s/0123456789abcdef0123456789abcdef"), env);
40
- expect(new URL(requests[0]!.url).pathname).toBe("/share.html");
41
- expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow, noarchive");
37
+ test("rewrites valid pane and layout share paths to the slim document", async () => {
38
+ const id = "0123456789abcdef0123456789abcdef";
39
+ for (const prefix of ["s", "l"]) {
40
+ const { env, requests } = fixture();
41
+ const response = await handleRequest(new Request(`https://term.example/${prefix}/${id}`), env);
42
+ expect(new URL(requests[0]!.url).pathname).toBe("/share.html");
43
+ expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow, noarchive");
44
+ }
42
45
  });
43
46
 
44
47
  test("serves the Apple app site association without touching static assets", async () => {