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
@@ -1,12 +1,12 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { TextAttributes } from "@opentui/core";
3
- import { testRender } from "../../renderers/opentui/test-utils";
4
- import { DialogProvider } from "@opentui-ui/dialog/react";
3
+ import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
5
4
  import type { ReactNode } from "react";
6
5
  import { act, createElement, useReducer } from "react";
7
6
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
8
7
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
9
8
  import { setSharedMarketDataForTests } from "../../plugins/registry";
9
+ import { useShortcut } from "../../react/input";
10
10
  import { cloneLayout, createDefaultConfig } from "../../types/config";
11
11
  import type { PluginRegistry } from "../../plugins/registry";
12
12
  import { setSharedRegistryForTests } from "../../plugins/registry";
@@ -17,6 +17,7 @@ import type { PaneProps } from "../../types/plugin";
17
17
  import { StockChart } from "../chart/stock-chart";
18
18
  import { StatusBar } from "./status-bar";
19
19
  import { Header } from "./header";
20
+ import { DetachedPaneShell } from "./detached-pane-shell";
20
21
  import {
21
22
  buildNativeWindowState,
22
23
  constrainFloatingRectToBounds,
@@ -76,7 +77,7 @@ function createShellPluginRegistry(options?: {
76
77
  openPaneSettingsFn: () => {},
77
78
  openCommandBar: () => {},
78
79
  updateLayoutFn: () => {},
79
- hideWidget: () => {},
80
+ hidePane: () => {},
80
81
  } as unknown as PluginRegistry;
81
82
  }
82
83
 
@@ -114,7 +115,7 @@ function createBrokerPortfolioRegistry(): PluginRegistry {
114
115
  openPaneSettingsFn: () => {},
115
116
  openCommandBar: () => {},
116
117
  updateLayoutFn: () => {},
117
- hideWidget: () => {},
118
+ hidePane: () => {},
118
119
  focusPaneFn: () => {},
119
120
  pinTicker: () => {},
120
121
  navigateTicker: () => {},
@@ -212,6 +213,31 @@ function createChartShellDataProvider(historyBySymbol: Record<string, PricePoint
212
213
  };
213
214
  }
214
215
 
216
+ async function emitKeypress(event: { name?: string; sequence?: string }) {
217
+ await act(async () => {
218
+ const keyEvent = {
219
+ ctrl: false,
220
+ meta: false,
221
+ option: false,
222
+ shift: false,
223
+ eventType: "press",
224
+ repeated: false,
225
+ defaultPrevented: false,
226
+ propagationStopped: false,
227
+ preventDefault() {
228
+ this.defaultPrevented = true;
229
+ },
230
+ stopPropagation() {
231
+ this.propagationStopped = true;
232
+ },
233
+ ...event,
234
+ };
235
+ testSetup!.renderer.keyInput.emit("keypress", keyEvent as any);
236
+ await testSetup!.renderOnce();
237
+ await testSetup!.renderOnce();
238
+ });
239
+ }
240
+
215
241
  function HeaderHarness({
216
242
  updateAvailable = null,
217
243
  updateProgress = null,
@@ -337,9 +363,9 @@ function ChartShellHarness({
337
363
 
338
364
  return (
339
365
  <AppContext value={{ state, dispatch }}>
340
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
366
+ <TestDialogProvider>
341
367
  <Shell pluginRegistry={pluginRegistry} />
342
- </DialogProvider>
368
+ </TestDialogProvider>
343
369
  </AppContext>
344
370
  );
345
371
  }
@@ -613,9 +639,9 @@ describe("Shell", () => {
613
639
 
614
640
  testSetup = await testRender(
615
641
  <AppContext value={{ state, dispatch: () => {} }}>
616
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
642
+ <TestDialogProvider>
617
643
  <Shell pluginRegistry={pluginRegistry} />
618
- </DialogProvider>
644
+ </TestDialogProvider>
619
645
  </AppContext>,
620
646
  { width: 40, height: 10 },
621
647
  );
@@ -655,7 +681,7 @@ describe("Shell", () => {
655
681
 
656
682
  testSetup = await testRender(
657
683
  <AppContext value={{ state, dispatch: () => {} }}>
658
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
684
+ <TestDialogProvider>
659
685
  <Shell
660
686
  pluginRegistry={pluginRegistry}
661
687
  desktopWindowBridge={{
@@ -665,7 +691,7 @@ describe("Shell", () => {
665
691
  subscribeDockPreview: () => () => {},
666
692
  }}
667
693
  />
668
- </DialogProvider>
694
+ </TestDialogProvider>
669
695
  </AppContext>,
670
696
  { width: 40, height: 10 },
671
697
  );
@@ -704,9 +730,9 @@ describe("Shell", () => {
704
730
 
705
731
  testSetup = await testRender(
706
732
  <AppContext value={{ state, dispatch: () => {} }}>
707
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
733
+ <TestDialogProvider>
708
734
  <Shell pluginRegistry={pluginRegistry} />
709
- </DialogProvider>
735
+ </TestDialogProvider>
710
736
  </AppContext>,
711
737
  { width: 40, height: 10 },
712
738
  );
@@ -823,9 +849,9 @@ describe("Shell", () => {
823
849
  setSharedRegistryForTests(pluginRegistry);
824
850
 
825
851
  testSetup = await testRender(
826
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
852
+ <TestDialogProvider>
827
853
  <BrokerShellHarness pluginRegistry={pluginRegistry} />
828
- </DialogProvider>,
854
+ </TestDialogProvider>,
829
855
  { width: 100, height: 24 },
830
856
  );
831
857
 
@@ -916,9 +942,9 @@ describe("Shell", () => {
916
942
 
917
943
  testSetup = await testRender(
918
944
  <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
919
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
945
+ <TestDialogProvider>
920
946
  <Shell pluginRegistry={createShellPluginRegistry()} />
921
- </DialogProvider>
947
+ </TestDialogProvider>
922
948
  </AppContext>,
923
949
  { width: 40, height: 10 },
924
950
  );
@@ -936,6 +962,177 @@ describe("Shell", () => {
936
962
  expect(updateLayout?.layout.dockRoot).toBeNull();
937
963
  });
938
964
 
965
+ test("closes the focused pane after double Escape", async () => {
966
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
967
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
968
+ if (!mainPane) throw new Error("missing default portfolio pane");
969
+
970
+ const singlePaneLayout = {
971
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
972
+ instances: [{ ...mainPane }],
973
+ floating: [],
974
+ };
975
+ const state = {
976
+ ...createInitialState({
977
+ ...config,
978
+ layout: cloneLayout(singlePaneLayout),
979
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
980
+ }),
981
+ focusedPaneId: "portfolio-list:main",
982
+ };
983
+ const actions: Array<any> = [];
984
+
985
+ testSetup = await testRender(
986
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
987
+ <TestDialogProvider>
988
+ <Shell pluginRegistry={createShellPluginRegistry()} />
989
+ </TestDialogProvider>
990
+ </AppContext>,
991
+ { width: 40, height: 10 },
992
+ );
993
+
994
+ await testSetup.renderOnce();
995
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
996
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
997
+
998
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
999
+
1000
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1001
+ expect(actions).toContainEqual({ type: "PUSH_LAYOUT_HISTORY" });
1002
+ expect(updateLayout?.layout.instances).toEqual([]);
1003
+ expect(updateLayout?.layout.floating).toEqual([]);
1004
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1005
+ });
1006
+
1007
+ test("closes on double Escape even when the focused pane handles the first Escape", async () => {
1008
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1009
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
1010
+ if (!mainPane) throw new Error("missing default portfolio pane");
1011
+
1012
+ let consumedEscapes = 0;
1013
+ function EscapeConsumerPane() {
1014
+ useShortcut((event) => {
1015
+ if (event.name !== "escape") return;
1016
+ consumedEscapes += 1;
1017
+ event.preventDefault();
1018
+ event.stopPropagation();
1019
+ });
1020
+ return <text>Escape Consumer</text>;
1021
+ }
1022
+
1023
+ const singlePaneLayout = {
1024
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
1025
+ instances: [{ ...mainPane }],
1026
+ floating: [],
1027
+ };
1028
+ const state = {
1029
+ ...createInitialState({
1030
+ ...config,
1031
+ layout: cloneLayout(singlePaneLayout),
1032
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
1033
+ }),
1034
+ focusedPaneId: "portfolio-list:main",
1035
+ };
1036
+ const actions: Array<any> = [];
1037
+
1038
+ testSetup = await testRender(
1039
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1040
+ <TestDialogProvider>
1041
+ <Shell
1042
+ pluginRegistry={createShellPluginRegistry({
1043
+ portfolioListComponent: EscapeConsumerPane,
1044
+ })}
1045
+ />
1046
+ </TestDialogProvider>
1047
+ </AppContext>,
1048
+ { width: 40, height: 10 },
1049
+ );
1050
+
1051
+ await testSetup.renderOnce();
1052
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1053
+ expect(consumedEscapes).toBe(1);
1054
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
1055
+
1056
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1057
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1058
+ expect(consumedEscapes).toBe(1);
1059
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1060
+ });
1061
+
1062
+ test("closes on double Escape while pane input is captured", async () => {
1063
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1064
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
1065
+ if (!mainPane) throw new Error("missing default portfolio pane");
1066
+
1067
+ const singlePaneLayout = {
1068
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
1069
+ instances: [{ ...mainPane }],
1070
+ floating: [],
1071
+ };
1072
+ const state = {
1073
+ ...createInitialState({
1074
+ ...config,
1075
+ layout: cloneLayout(singlePaneLayout),
1076
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
1077
+ }),
1078
+ focusedPaneId: "portfolio-list:main",
1079
+ inputCaptured: true,
1080
+ };
1081
+ const actions: Array<any> = [];
1082
+
1083
+ testSetup = await testRender(
1084
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1085
+ <TestDialogProvider>
1086
+ <Shell pluginRegistry={createShellPluginRegistry()} />
1087
+ </TestDialogProvider>
1088
+ </AppContext>,
1089
+ { width: 40, height: 10 },
1090
+ );
1091
+
1092
+ await testSetup.renderOnce();
1093
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1094
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
1095
+
1096
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1097
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1098
+ expect(updateLayout?.layout.dockRoot).toBeNull();
1099
+ });
1100
+
1101
+ test("closes a detached pane window after double Escape", async () => {
1102
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1103
+ const state = {
1104
+ ...createInitialState(config),
1105
+ focusedPaneId: "portfolio-list:main",
1106
+ };
1107
+ const closedPaneIds: string[] = [];
1108
+
1109
+ testSetup = await testRender(
1110
+ <AppContext value={{ state, dispatch: () => {} }}>
1111
+ <TestDialogProvider>
1112
+ <DetachedPaneShell
1113
+ pluginRegistry={createShellPluginRegistry()}
1114
+ desktopWindowBridge={{
1115
+ kind: "detached",
1116
+ paneId: "portfolio-list:main",
1117
+ subscribeState: () => () => {},
1118
+ closeDetachedPane: async (paneId) => {
1119
+ closedPaneIds.push(paneId);
1120
+ },
1121
+ }}
1122
+ />
1123
+ </TestDialogProvider>
1124
+ </AppContext>,
1125
+ { width: 40, height: 10 },
1126
+ );
1127
+
1128
+ await testSetup.renderOnce();
1129
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1130
+ expect(closedPaneIds).toEqual([]);
1131
+
1132
+ await emitKeypress({ name: "escape", sequence: "\u001b" });
1133
+ expect(closedPaneIds).toEqual(["portfolio-list:main"]);
1134
+ });
1135
+
939
1136
  test("closes the focused floating pane with Ctrl+W", async () => {
940
1137
  const config = createDefaultConfig("/tmp/gloomberb-shell-test");
941
1138
  const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
@@ -946,6 +1143,7 @@ describe("Shell", () => {
946
1143
  dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
947
1144
  instances: [{ ...mainPane }, { ...detailPane }],
948
1145
  floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8 }],
1146
+ detached: [],
949
1147
  };
950
1148
  const state = {
951
1149
  ...createInitialState({
@@ -959,9 +1157,9 @@ describe("Shell", () => {
959
1157
 
960
1158
  testSetup = await testRender(
961
1159
  <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
962
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
1160
+ <TestDialogProvider>
963
1161
  <Shell pluginRegistry={createShellPluginRegistry()} />
964
- </DialogProvider>
1162
+ </TestDialogProvider>
965
1163
  </AppContext>,
966
1164
  { width: 40, height: 12 },
967
1165
  );
@@ -989,6 +1187,7 @@ describe("Shell", () => {
989
1187
  dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
990
1188
  instances: [{ ...mainPane }, { ...detailPane }],
991
1189
  floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8 }],
1190
+ detached: [],
992
1191
  };
993
1192
  const state = {
994
1193
  ...createInitialState({
@@ -1002,9 +1201,9 @@ describe("Shell", () => {
1002
1201
 
1003
1202
  testSetup = await testRender(
1004
1203
  <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1005
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
1204
+ <TestDialogProvider>
1006
1205
  <Shell pluginRegistry={createShellPluginRegistry()} />
1007
- </DialogProvider>
1206
+ </TestDialogProvider>
1008
1207
  </AppContext>,
1009
1208
  { width: 40, height: 12 },
1010
1209
  );
@@ -1020,6 +1219,49 @@ describe("Shell", () => {
1020
1219
  expect(updateLayout?.layout.floating).toEqual([]);
1021
1220
  });
1022
1221
 
1222
+ test("closes the focused floating pane with Ctrl+W while plugin input is captured", async () => {
1223
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
1224
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
1225
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
1226
+ if (!mainPane || !detailPane) throw new Error("missing default panes");
1227
+
1228
+ const mixedLayout = {
1229
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
1230
+ instances: [{ ...mainPane }, { ...detailPane }],
1231
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8 }],
1232
+ detached: [],
1233
+ };
1234
+ const state = {
1235
+ ...createInitialState({
1236
+ ...config,
1237
+ layout: cloneLayout(mixedLayout),
1238
+ layouts: [{ name: "Default", layout: cloneLayout(mixedLayout) }],
1239
+ }),
1240
+ focusedPaneId: "ticker-detail:main",
1241
+ inputCaptured: true,
1242
+ };
1243
+ const actions: Array<any> = [];
1244
+
1245
+ testSetup = await testRender(
1246
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
1247
+ <TestDialogProvider>
1248
+ <Shell pluginRegistry={createShellPluginRegistry()} />
1249
+ </TestDialogProvider>
1250
+ </AppContext>,
1251
+ { width: 40, height: 12 },
1252
+ );
1253
+
1254
+ await testSetup.renderOnce();
1255
+ await act(async () => {
1256
+ testSetup!.mockInput.pressKey("w", { ctrl: true });
1257
+ await testSetup!.renderOnce();
1258
+ });
1259
+
1260
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
1261
+ expect(updateLayout?.layout.instances.map((instance: { instanceId: string }) => instance.instanceId)).toEqual(["portfolio-list:main"]);
1262
+ expect(updateLayout?.layout.floating).toEqual([]);
1263
+ });
1264
+
1023
1265
  test("keeps a floating pane at the preview rect after a free drag release", () => {
1024
1266
  const config = createDefaultConfig("/tmp/gloomberb-shell-drag-test");
1025
1267
  const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
@@ -1153,7 +1395,7 @@ describe("Shell", () => {
1153
1395
 
1154
1396
  testSetup = await testRender(
1155
1397
  <AppContext value={{ state, dispatch: () => {} }}>
1156
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
1398
+ <TestDialogProvider>
1157
1399
  <Shell pluginRegistry={createShellPluginRegistry({
1158
1400
  tickerDetailComponent: ({ width, height }) => (
1159
1401
  <box flexDirection="column" width={width} height={height}>
@@ -1164,7 +1406,7 @@ describe("Shell", () => {
1164
1406
  </box>
1165
1407
  ),
1166
1408
  })} />
1167
- </DialogProvider>
1409
+ </TestDialogProvider>
1168
1410
  </AppContext>,
1169
1411
  { width: 40, height: 12 },
1170
1412
  );
@@ -9,7 +9,6 @@ import {
9
9
  MIN_FLOAT_HEIGHT,
10
10
  MIN_FLOAT_WIDTH,
11
11
  applyDrop,
12
- findDockLeaf,
13
12
  floatAtRect,
14
13
  floatPane,
15
14
  getDockDividerLayouts,
@@ -22,7 +21,6 @@ import {
22
21
  resolveDocked,
23
22
  resolveFloating,
24
23
  simulateDrop,
25
- swapPanes,
26
24
  type DockDividerLayout,
27
25
  type DockLeafLayout,
28
26
  type DropTarget,
@@ -45,6 +43,7 @@ import {
45
43
  selectStatusBarVisible,
46
44
  } from "../../state/selectors-ui";
47
45
  import { colors } from "../../theme/colors";
46
+ import { useThemeColors } from "../../theme/theme-context";
48
47
  import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
49
48
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
50
49
  import { FloatingPaneWrapper } from "./floating-pane";
@@ -60,6 +59,11 @@ import {
60
59
  getShortcutDisplayMode,
61
60
  type ShortcutDisplayMode,
62
61
  } from "../../utils/shortcut-labels";
62
+ import {
63
+ createDoubleEscapeCloseState,
64
+ recordDoubleEscapeClose,
65
+ resetDoubleEscapeClose,
66
+ } from "../../utils/double-escape-close";
63
67
 
64
68
  interface ShellProps {
65
69
  pluginRegistry: PluginRegistry;
@@ -218,6 +222,14 @@ export function resolvePaneManagementShortcut(
218
222
  return null;
219
223
  }
220
224
 
225
+ function inputCaptureAllowsPaneManagementShortcut(
226
+ shortcut: PaneManagementShortcut,
227
+ event: Pick<KeyEventLike, "meta" | "super" | "targetEditable">,
228
+ ): boolean {
229
+ if (shortcut !== "close") return false;
230
+ return event.meta || event.super || event.targetEditable !== true;
231
+ }
232
+
221
233
  export function resolveAppHeaderHeightCells(options: { titleBarOverlay?: boolean; cellHeightPx?: number }): number {
222
234
  if (!options.titleBarOverlay || !options.cellHeightPx || options.cellHeightPx <= 0) return DEFAULT_HEADER_HEIGHT;
223
235
  return TITLEBAR_OVERLAY_HEIGHT_PX / options.cellHeightPx;
@@ -588,8 +600,8 @@ function resolveHeaderHitAreas(
588
600
  actionStart: number | null;
589
601
  closeStart: number | null;
590
602
  } {
591
- // When focused, the header renders ┌─...─┐ adding 2 chars on each side
592
- let rightEdge = options.focused ? width - 2 : width;
603
+ // Focused panes and all floating panes render ┌─...─┐, adding 2 chars on each side.
604
+ let rightEdge = options.focused || options.floating ? width - 2 : width;
593
605
  let closeStart: number | null = null;
594
606
  let actionStart: number | null = null;
595
607
 
@@ -771,6 +783,7 @@ function resolveExternalDockPreview(
771
783
  }
772
784
 
773
785
  export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview }: ShellProps) {
786
+ useThemeColors();
774
787
  const dispatch = useAppDispatch();
775
788
  const config = useAppSelector((state) => state.config);
776
789
  const paneState = useAppSelector((state) => state.paneState);
@@ -802,6 +815,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
802
815
  const overlayOpen = commandBarOpen || dialogOpen || !!menuState;
803
816
 
804
817
  const dragRef = useRef<DragMode | null>(null);
818
+ const doubleEscapeCloseRef = useRef(createDoubleEscapeCloseState());
805
819
  const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
806
820
  const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
807
821
  const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
@@ -958,15 +972,39 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
958
972
  return true;
959
973
  }, [contentHeight, persistLayout, visibleLayout, width]);
960
974
 
975
+ useEffect(() => {
976
+ if (overlayOpen) {
977
+ resetDoubleEscapeClose(doubleEscapeCloseRef.current);
978
+ }
979
+ }, [overlayOpen]);
980
+
961
981
  useShortcut((event) => {
962
- if (event.name === "escape") {
982
+ const isEscape = event.name === "escape" || event.name === "esc";
983
+ if (isEscape) {
984
+ const doubleEscapeState = doubleEscapeCloseRef.current;
985
+ if (!dragRef.current && !overlayOpen) {
986
+ if (recordDoubleEscapeClose(doubleEscapeState, focusedPaneId, Date.now()) && closeFocusedPane()) {
987
+ event.preventDefault();
988
+ event.stopPropagation();
989
+ return;
990
+ }
991
+ } else {
992
+ resetDoubleEscapeClose(doubleEscapeState);
993
+ }
994
+
963
995
  if (!dragRef.current) return;
964
996
  cancelActiveDrag();
965
- return;
997
+ event.preventDefault();
998
+ event.stopPropagation();
999
+ } else {
1000
+ resetDoubleEscapeClose(doubleEscapeCloseRef.current);
966
1001
  }
1002
+ }, { phase: "before" });
967
1003
 
1004
+ useShortcut((event) => {
968
1005
  const shortcut = resolvePaneManagementShortcut(event);
969
- if (!shortcut || dragRef.current || overlayOpen || inputCaptured) return;
1006
+ if (!shortcut || dragRef.current || overlayOpen) return;
1007
+ if (inputCaptured && !inputCaptureAllowsPaneManagementShortcut(shortcut, event)) return;
970
1008
 
971
1009
  let handled = false;
972
1010
  switch (shortcut) {
@@ -1701,7 +1739,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1701
1739
  "data-active": active ? "true" : "false",
1702
1740
  style: { "--divider-color": active ? colors.borderFocused : colors.border } as any,
1703
1741
  } : {})}
1704
- onMouseDown={nativePaneChrome ? (event) => startNativeDividerDrag(divider, event) : undefined}
1742
+ onMouseDown={nativePaneChrome ? (event: ShellMouseEvent) => startNativeDividerDrag(divider, event) : undefined}
1705
1743
  onMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
1706
1744
  onMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
1707
1745
  />
@@ -1,6 +1,7 @@
1
1
  import { Box, Span, Text, TextAttributes, contextMenuDivider, useContextMenu, useUiCapabilities } from "../../ui";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { colors, hoverBg } from "../../theme/colors";
4
+ import { useThemeColors } from "../../theme/theme-context";
4
5
  import { useAppDispatch, useAppSelector, useFocusedTicker } from "../../state/app-context";
5
6
  import {
6
7
  selectActiveLayoutIndex,
@@ -57,6 +58,7 @@ function truncate(text: string, width: number): string {
57
58
  }
58
59
 
59
60
  export function StatusBar() {
61
+ useThemeColors();
60
62
  const { nativePaneChrome, nativeContextMenu } = useUiCapabilities();
61
63
  const { showContextMenu } = useContextMenu();
62
64
  const registry = getSharedRegistry();
@@ -1,4 +1,4 @@
1
- import { AsciiText, Box, Input, Span, Strong, Text, Underline, useUiHost } from "../../ui";
1
+ import { AsciiText, Box, Span, Strong, Text, Underline, useUiHost } from "../../ui";
2
2
  import { useState, useCallback, useEffect, useRef, useMemo, type RefObject } from "react";
3
3
  import { useShortcut, useViewport } from "../../react/input";
4
4
  import { TextAttributes } from "../../ui";
@@ -963,7 +963,6 @@ function PortfolioStep({
963
963
 
964
964
  // broker-fields: dynamically render fields from the broker's configSchema
965
965
  if (!selectedBrokerId) return null;
966
- const currentField = brokerFields[brokerFieldIdx];
967
966
  const values = brokerValues[selectedBrokerId] ?? {};
968
967
 
969
968
  return (
@@ -9,7 +9,7 @@ import type {
9
9
  PaneSettingTextField,
10
10
  } from "../types/plugin";
11
11
  import type { PluginRegistry } from "../plugins/registry";
12
- import { colors } from "../theme/colors";
12
+ import { blendHex, colors } from "../theme/colors";
13
13
  import { Button, DialogFrame, ListView, MultiSelectDialogContent, TextField } from "./ui";
14
14
  import { NativeSelect, openNativeSelect, type NativeSelectElement } from "./ui/native-select";
15
15
 
@@ -80,6 +80,14 @@ function desktopText(weight?: number) {
80
80
  return weight ? { ...DESKTOP_TEXT_STYLE, fontWeight: weight } : DESKTOP_TEXT_STYLE;
81
81
  }
82
82
 
83
+ function desktopSubtleSurface(): string {
84
+ return blendHex(colors.panel, colors.bg, 0.22);
85
+ }
86
+
87
+ function desktopHoverSurface(): string {
88
+ return blendHex(colors.bg, colors.borderFocused, 0.08);
89
+ }
90
+
83
91
  function DesktopDialogSurface({
84
92
  title,
85
93
  subtitle,
@@ -149,7 +157,7 @@ function DesktopSwitch({
149
157
  flexDirection="row"
150
158
  alignItems="center"
151
159
  justifyContent={checked ? "flex-end" : "flex-start"}
152
- backgroundColor={checked ? colors.selected : "rgba(255, 255, 255, 0.07)"}
160
+ backgroundColor={checked ? colors.selected : desktopSubtleSurface()}
153
161
  onMouseDown={(event: any) => {
154
162
  event.stopPropagation?.();
155
163
  event.preventDefault?.();
@@ -182,7 +190,7 @@ function DesktopValuePill({ value }: { value: string }) {
182
190
  <Box
183
191
  flexDirection="row"
184
192
  alignItems="center"
185
- backgroundColor="rgba(255, 255, 255, 0.06)"
193
+ backgroundColor={desktopSubtleSurface()}
186
194
  style={{
187
195
  border: `1px solid ${colors.border}`,
188
196
  borderRadius: 6,
@@ -236,7 +244,7 @@ function DesktopSettingsRow({
236
244
  <Box
237
245
  flexDirection="column"
238
246
  minHeight={field.description ? undefined : 2}
239
- backgroundColor={hovered || selected ? "rgba(255, 255, 255, 0.045)" : "transparent"}
247
+ backgroundColor={hovered || selected ? desktopHoverSurface() : "transparent"}
240
248
  onMouseMove={onHover}
241
249
  onMouseDown={field.type === "select" ? undefined : (event: any) => {
242
250
  event.stopPropagation?.();
@@ -1,7 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act } from "react";
3
- import { testRender } from "../renderers/opentui/test-utils";
4
- import { DialogProvider } from "@opentui-ui/dialog/react";
3
+ import { TestDialogProvider, testRender } from "../renderers/opentui/test-utils";
5
4
  import { PaneTemplateTextareaStep } from "./pane-template-wizard";
6
5
 
7
6
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
@@ -18,7 +17,7 @@ describe("PaneTemplateTextareaStep", () => {
18
17
  let resolved = "";
19
18
 
20
19
  testSetup = await testRender(
21
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
20
+ <TestDialogProvider>
22
21
  <PaneTemplateTextareaStep
23
22
  resolve={(value) => { resolved = value; }}
24
23
  dialogId="textarea-test"
@@ -30,7 +29,7 @@ describe("PaneTemplateTextareaStep", () => {
30
29
  defaultValue: "Find cash-generative semiconductor suppliers",
31
30
  }}
32
31
  />
33
- </DialogProvider>,
32
+ </TestDialogProvider>,
34
33
  { width: 80, height: 16 },
35
34
  );
36
35