gloomberb 0.8.2 → 0.9.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 (111) hide show
  1. package/README.md +9 -7
  2. package/package.json +18 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/patches/electrobun@1.18.1.patch +85 -0
  5. package/src/app/global-shortcuts.test.tsx +66 -4
  6. package/src/app/global-shortcuts.ts +12 -6
  7. package/src/assets/gloomberb-logo-windows.ico +0 -0
  8. package/src/assets/gloomberb-logo-windows.png +0 -0
  9. package/src/assets/gloomberb-logo.ico +0 -0
  10. package/src/components/command-bar/action-runtime.ts +1 -0
  11. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  12. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  13. package/src/components/command-bar/list/view.tsx +3 -1
  14. package/src/components/command-bar/pane-templates/items.ts +4 -1
  15. package/src/components/command-bar/panel/body.tsx +1 -0
  16. package/src/components/command-bar/routes/root/results.ts +0 -30
  17. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  18. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  19. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  20. package/src/components/command-bar/routing/launch-request.ts +14 -7
  21. package/src/components/command-bar/routing/list-state.ts +1 -3
  22. package/src/components/command-bar/surface/index.test.tsx +76 -4
  23. package/src/components/command-bar/surface/index.tsx +0 -2
  24. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  25. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  26. package/src/components/command-bar/view-model.ts +3 -3
  27. package/src/components/layout/detached-pane-shell.tsx +8 -4
  28. package/src/components/layout/header.tsx +13 -6
  29. package/src/components/layout/shell/index.test.tsx +120 -2
  30. package/src/components/layout/shell/index.tsx +2 -0
  31. package/src/components/layout/shell/menu.ts +1 -7
  32. package/src/components/layout/shell/pane/actions.ts +8 -0
  33. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  34. package/src/components/layout/shell/shortcuts.ts +14 -6
  35. package/src/components/layout/shell/window-mode/index.ts +8 -3
  36. package/src/components/layout/titlebar-overlay.ts +16 -0
  37. package/src/components/layout/window-controls.tsx +90 -0
  38. package/src/components/layout/window-edit/presentation.ts +4 -4
  39. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  40. package/src/components/ticker/list-table-view.tsx +6 -0
  41. package/src/components/ui/remote-image.tsx +1 -14
  42. package/src/components/ui/tabs.tsx +95 -80
  43. package/src/components/ui/ui.test.tsx +54 -0
  44. package/src/core/state/app/types.ts +12 -4
  45. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  46. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  47. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  48. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  49. package/src/plugins/builtin/help/components.tsx +1 -1
  50. package/src/plugins/builtin/help/index.test.tsx +105 -30
  51. package/src/plugins/builtin/help/index.tsx +324 -168
  52. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  53. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  54. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  55. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  57. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  58. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  59. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  60. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  61. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  62. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  63. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  64. package/src/plugins/builtin/substack/api/store.ts +186 -0
  65. package/src/plugins/builtin/substack/api/types.ts +40 -0
  66. package/src/plugins/builtin/substack/api.test.ts +298 -0
  67. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  68. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  69. package/src/plugins/builtin/substack/content.ts +478 -0
  70. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  71. package/src/plugins/builtin/substack/index.tsx +49 -0
  72. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  73. package/src/plugins/builtin/substack/model.test.ts +155 -0
  74. package/src/plugins/builtin/substack/normalize.ts +343 -0
  75. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  76. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  77. package/src/plugins/builtin/substack/pane.tsx +500 -0
  78. package/src/plugins/builtin/substack/table.ts +111 -0
  79. package/src/plugins/builtin/substack/types.ts +71 -0
  80. package/src/plugins/builtin/substack/utils.ts +131 -0
  81. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  82. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  83. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  84. package/src/plugins/catalog-ui.ts +2 -0
  85. package/src/plugins/pane-manager/layout-state.ts +11 -0
  86. package/src/plugins/pane-manager.ts +1 -1
  87. package/src/react/use-throttled-commit-value.ts +116 -0
  88. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  89. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  90. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
  91. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  92. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  93. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  94. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  95. package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
  96. package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
  97. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
  98. package/src/renderers/electrobun/bun/index.ts +28 -6
  99. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  100. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  101. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  102. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  103. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  104. package/src/renderers/electrobun/view/styles.css +20 -0
  105. package/src/renderers/electrobun/view/ui-host.tsx +14 -0
  106. package/src/state/app/context/index.test.tsx +16 -0
  107. package/src/tickers/search/index.ts +1 -0
  108. package/src/ui/host.tsx +2 -0
  109. package/src/updater.test.ts +47 -2
  110. package/src/updater.ts +67 -10
  111. package/src/version.ts +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  rankTickerSearchItems,
7
7
  upsertTickerFromSearchResult,
8
8
  type TickerSearchCandidate,
9
+ type TickerSearchInstrumentClass,
9
10
  } from "../../../../tickers/search";
10
11
  import type { ResultItem } from "../../list/model";
11
12
  import {
@@ -45,12 +46,15 @@ export function useCommandBarTickerSearchActions({
45
46
  }, [tickerRepository, dispatch, pluginRegistry.events, focusTicker, closeAll]);
46
47
 
47
48
  const mapTickerSearchCandidateToResultItem = useCallback((candidate: TickerSearchCandidate): ResultItem => {
49
+ const detail = formatTickerSearchDetail(candidate);
50
+ const right = formatTickerSearchRight(candidate);
51
+
48
52
  if (candidate.kind === "ticker" && candidate.ticker) {
49
53
  return {
50
54
  id: candidate.id,
51
55
  label: candidate.label,
52
- detail: candidate.detail,
53
- right: candidate.right,
56
+ detail,
57
+ right,
54
58
  category: candidate.category,
55
59
  kind: "ticker",
56
60
  secondaryAction: () => {
@@ -67,8 +71,8 @@ export function useCommandBarTickerSearchActions({
67
71
  return {
68
72
  id: candidate.id,
69
73
  label: candidate.label,
70
- detail: candidate.detail,
71
- right: candidate.right,
74
+ detail,
75
+ right,
72
76
  category: candidate.category,
73
77
  kind: "search",
74
78
  secondaryAction: () => openTickerResearch(candidate.result!, { forceNewPane: true }),
@@ -140,3 +144,37 @@ export function useCommandBarTickerSearchActions({
140
144
  writeTickerSearchCache,
141
145
  };
142
146
  }
147
+
148
+ function formatTickerSearchRight(candidate: TickerSearchCandidate): string | undefined {
149
+ const assetClass = formatInstrumentClass(candidate.instrumentClass);
150
+ const exchange = candidate.exchangeLabel || candidate.primaryExchangeLabel || candidate.right || "";
151
+ if (!exchange) return assetClass;
152
+ if (assetClass === "Other") return exchange;
153
+ return `${assetClass} ${exchange}`;
154
+ }
155
+
156
+ function formatTickerSearchDetail(candidate: TickerSearchCandidate): string {
157
+ const metadata = [
158
+ formatInstrumentClass(candidate.instrumentClass),
159
+ candidate.exchangeLabel,
160
+ candidate.primaryExchangeLabel && candidate.primaryExchangeLabel !== candidate.exchangeLabel
161
+ ? candidate.primaryExchangeLabel
162
+ : null,
163
+ ].filter((value): value is string => !!value);
164
+ return metadata.length > 0
165
+ ? `${candidate.detail} | ${metadata.join(" | ")}`
166
+ : candidate.detail;
167
+ }
168
+
169
+ function formatInstrumentClass(instrumentClass: TickerSearchInstrumentClass): string {
170
+ switch (instrumentClass) {
171
+ case "equity":
172
+ return "Equity";
173
+ case "fund":
174
+ return "Fund";
175
+ case "derivative":
176
+ return "Derivative";
177
+ case "other":
178
+ return "Other";
179
+ }
180
+ }
@@ -65,7 +65,10 @@ export function useTickerSearchRouteResults(options: {
65
65
 
66
66
  const searchQuery = routeQuery.trim();
67
67
  if (!searchQuery) {
68
- resetTickerSearchRoute();
68
+ searchRequestIdRef.current += 1;
69
+ setTickerSearchPending(false);
70
+ setTickerSearchResults(localTickerSearchResultItems(undefined, { limit: 8 }));
71
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
69
72
  return;
70
73
  }
71
74
 
@@ -1,17 +1,17 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { PluginRegistry } from "../../../plugins/registry";
3
+ import type { AppState } from "../../../state/app/context";
3
4
  import type { CommandDef } from "../../../types/plugin";
4
5
 
5
- interface CommandBarPluginLaunchRequest {
6
- kind: "plugin-command";
7
- commandId: string;
8
- sequence: number;
9
- }
10
-
11
6
  interface UseCommandBarLaunchRequestOptions {
12
7
  activeTickerSymbol: string | null;
13
- commandBarLaunchRequest: CommandBarPluginLaunchRequest | null;
8
+ commandBarLaunchRequest: AppState["commandBarLaunchRequest"];
14
9
  commandBarOpen: boolean;
10
+ openModeRoute: (
11
+ screen: "ticker-search" | "plugins" | "layout",
12
+ initialQuery?: string,
13
+ payload?: Record<string, unknown>,
14
+ ) => void;
15
15
  openPluginCommandWorkflow: (
16
16
  command: CommandDef,
17
17
  options?: { values?: Record<string, string> },
@@ -23,6 +23,7 @@ export function useCommandBarLaunchRequest({
23
23
  activeTickerSymbol,
24
24
  commandBarLaunchRequest,
25
25
  commandBarOpen,
26
+ openModeRoute,
26
27
  openPluginCommandWorkflow,
27
28
  pluginRegistry,
28
29
  }: UseCommandBarLaunchRequestOptions) {
@@ -38,6 +39,11 @@ export function useCommandBarLaunchRequest({
38
39
  if (processedLaunchSequenceRef.current === launch.sequence) return;
39
40
  processedLaunchSequenceRef.current = launch.sequence;
40
41
 
42
+ if (launch.kind === "ticker-search") {
43
+ openModeRoute("ticker-search", launch.query ?? "");
44
+ return;
45
+ }
46
+
41
47
  const command = pluginRegistry.commands.get(launch.commandId);
42
48
  if (!command?.wizard || command.wizard.length === 0) return;
43
49
  let values: Record<string, string> | undefined;
@@ -53,6 +59,7 @@ export function useCommandBarLaunchRequest({
53
59
  activeTickerSymbol,
54
60
  commandBarLaunchRequest,
55
61
  commandBarOpen,
62
+ openModeRoute,
56
63
  openPluginCommandWorkflow,
57
64
  pluginRegistry,
58
65
  ]);
@@ -125,9 +125,7 @@ function buildRouteListState(options: BuildRouteListStateOptions): ListScreenSta
125
125
  };
126
126
  }
127
127
  case "ticker-search": {
128
- const results = currentRoute.query.trim()
129
- ? tickerSearchResults.map((item) => adaptTickerSearchRouteResult(item, currentRoute.payload))
130
- : [];
128
+ const results = tickerSearchResults.map((item) => adaptTickerSearchRouteResult(item, currentRoute.payload));
131
129
  const emptyState = getEmptyState("search", currentRoute.query, currentRoute.query);
132
130
  return {
133
131
  kind: "mode",
@@ -81,13 +81,35 @@ describe("CommandBar", () => {
81
81
 
82
82
  const frame = testSetup.captureCharFrame();
83
83
  expect(frame).toContain("Commands");
84
- expect(frame).toContain("Tickers");
85
- expect(frame).toContain("AAPL");
84
+ expect(frame).not.toContain("Tickers");
86
85
  expect(frame).toContain("Panes");
87
86
  expect(frame).toContain("Portfolio");
88
87
  expect(frame).toContain("Help");
89
88
  });
90
89
 
90
+ test("keeps generic command filtering separate from ticker search", async () => {
91
+ const searchQueries: string[] = [];
92
+ testSetup = await testRender(<CommandBarHarness
93
+ query="MSFT"
94
+ dataProvider={makeDataProvider(async (query) => {
95
+ searchQueries.push(query);
96
+ return [];
97
+ })}
98
+ />, {
99
+ width: 80,
100
+ height: 24,
101
+ });
102
+
103
+ await testSetup.renderOnce();
104
+ await Bun.sleep(260);
105
+ await testSetup.renderOnce();
106
+
107
+ const frame = testSetup.captureCharFrame();
108
+ expect(frame).not.toContain("Tickers");
109
+ expect(frame).not.toContain("Microsoft Corp.");
110
+ expect(searchQueries).toEqual([]);
111
+ });
112
+
91
113
  test("runs check for updates from the command bar", async () => {
92
114
  const calls: number[] = [];
93
115
 
@@ -416,6 +438,56 @@ describe("CommandBar", () => {
416
438
  expect(frame).toContain("AMD");
417
439
  });
418
440
 
441
+ test("opens ticker search from a launch request with saved ticker metadata", async () => {
442
+ testSetup = await testRender(<CommandBarHarness
443
+ query=""
444
+ extraTickers={[makeTicker("BRK.B", "Berkshire Hathaway Inc.", {
445
+ exchange: "NYSE",
446
+ assetCategory: "STK",
447
+ })]}
448
+ configureState={(state) => ({
449
+ ...state,
450
+ commandBarLaunchRequest: {
451
+ kind: "ticker-search",
452
+ query: "",
453
+ sequence: 1,
454
+ },
455
+ })}
456
+ />, {
457
+ width: 100,
458
+ height: 24,
459
+ });
460
+
461
+ const frame = await waitForFrameToContain("Security Description");
462
+ expectSingleBackControl(frame);
463
+ expect(frame).toContain("BRK.B");
464
+ expect(frame).toContain("Equity NYSE");
465
+ });
466
+
467
+ test("opens ticker search when activating the Ticker Research pane item without a ticker", async () => {
468
+ testSetup = await testRender(<CommandBarHarness query="ticker research" />, {
469
+ width: 100,
470
+ height: 24,
471
+ });
472
+
473
+ await testSetup.renderOnce();
474
+ const rootFrame = testSetup.captureCharFrame();
475
+ const tickerResearchRow = rootFrame
476
+ .split("\n")
477
+ .find((line) => line.includes("Ticker Research"));
478
+ expect(tickerResearchRow).toMatch(/\bT\s*$/);
479
+
480
+ await act(async () => {
481
+ testSetup!.mockInput.pressEnter();
482
+ await testSetup!.renderOnce();
483
+ });
484
+
485
+ const frame = testSetup.captureCharFrame();
486
+ expectSingleBackControl(frame);
487
+ expect(frame).toContain("Security Description");
488
+ expect(frame).toContain("Search tickers");
489
+ });
490
+
419
491
  test("keeps typed prefixes in the root query until a result is activated", async () => {
420
492
  testSetup = await testRender(<CommandBarHarness query="DES " />, {
421
493
  width: 80,
@@ -763,8 +835,8 @@ describe("CommandBar", () => {
763
835
  const rows = frame.split("\n");
764
836
  const savedHeadings = frame.split("\n").filter((line) => line.trim() === "Saved");
765
837
  const otherListingsHeadings = frame.split("\n").filter((line) => line.trim() === "Other Listings");
766
- const aaplRow = rows.findIndex((line) => line.trimStart().startsWith("AAPL") && line.includes("NASDAQ"));
767
- const appRow = rows.findIndex((line) => line.trimStart().startsWith("APP") && line.includes("NASDAQ"));
838
+ const aaplRow = rows.findIndex((line) => line.trimStart().startsWith("AAPL"));
839
+ const appRow = rows.findIndex((line) => line.trimStart().startsWith("APP"));
768
840
  expect(savedHeadings).toHaveLength(1);
769
841
  expect(otherListingsHeadings).toHaveLength(1);
770
842
  expect(aaplRow).toBeGreaterThanOrEqual(0);
@@ -110,7 +110,6 @@ export function CommandBar({
110
110
  getWorkflowFieldStringValue,
111
111
  getWorkflowInputRef,
112
112
  localTickerSearchResultItems,
113
- mapTickerSearchCandidateToResultItem,
114
113
  moveWorkflowFocus,
115
114
  nonShortcutPaneTemplateItems,
116
115
  openInlineConfirm,
@@ -193,7 +192,6 @@ export function CommandBar({
193
192
  getTickers: getTickerSearchTickers,
194
193
  hasPaneSettings,
195
194
  localTickerSearchResultItems,
196
- mapTickerSearchCandidateToResultItem,
197
195
  nativeListScrollRef,
198
196
  nonShortcutPaneTemplateItems,
199
197
  openModeRoute,
@@ -158,6 +158,7 @@ describe("CommandBar pane and layout routes", () => {
158
158
  expect(frame).toContain("Float Pane");
159
159
  expect(frame).toContain("Undo Layout Change");
160
160
  expect(frame).toContain("Current Layout");
161
+ expect(frame).toContain("Close All Floating Panes");
161
162
  });
162
163
 
163
164
  test("runs layout actions directly from root search", async () => {
@@ -174,6 +174,7 @@ function makePluginRegistry(hasPaneSettings: (paneId: string) => boolean = () =>
174
174
  paneId: TICKER_RESEARCH_PANE_ID,
175
175
  label: "Ticker Research",
176
176
  description: "Open another research pane",
177
+ shortcut: { prefix: "T", argPlaceholder: "ticker", argKind: "ticker" },
177
178
  }],
178
179
  ["new-chat-pane", {
179
180
  id: "new-chat-pane",
@@ -40,7 +40,7 @@ export function resolveCommandBarMode(query: string, commandList?: Command[]): C
40
40
  const match = matchPrefix(query, commandList);
41
41
 
42
42
  if (!query.trim()) {
43
- return { kind: "default", badge: "BROWSE", hint: "Type a command, ticker, or prefix" };
43
+ return { kind: "default", badge: "BROWSE", hint: "Type a command or prefix" };
44
44
  }
45
45
 
46
46
  if (!match) {
@@ -100,9 +100,9 @@ export function getEmptyState(mode: CommandBarMode, query: string, searchQuery?:
100
100
  return { label: "No layout actions match", detail: query.trim() || "Focused-pane and layout actions will appear here" };
101
101
  default:
102
102
  if (query.trim()) {
103
- return { label: `No matches for "${query.trim()}"`, detail: "Try a ticker, command name, or prefix" };
103
+ return { label: `No matches for "${query.trim()}"`, detail: "Try a command name or prefix. Use T for ticker search" };
104
104
  }
105
- return { label: "No results yet", detail: "Recent tickers and suggested commands will appear here" };
105
+ return { label: "No results yet", detail: "Suggested commands will appear here" };
106
106
  }
107
107
  }
108
108
 
@@ -12,7 +12,8 @@ import { PaneBodyFrame, getPaneWindowAttributes } from "./pane/frame";
12
12
  import { PaneContent } from "./pane/content";
13
13
  import { resolvePaneBodyFrame } from "./pane/sizing";
14
14
  import { getPaneDisplayTitle } from "./pane/title";
15
- import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
15
+ import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
16
+ import { WindowControls } from "./window-controls";
16
17
  import {
17
18
  createDoubleEscapeCloseState,
18
19
  recordDoubleEscapeClose,
@@ -41,7 +42,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
41
42
  typeof document === "undefined" ? true : document.hasFocus()
42
43
  ));
43
44
  const { width, height } = useViewport();
44
- const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay } = useUiCapabilities();
45
+ const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay, windowControls } = useUiCapabilities();
46
+ const showWindowControls = windowControls === "windows";
47
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
45
48
  const instance = useAppSelector((state) => findPaneInstance(state.config.layout, desktopWindowBridge.paneId) ?? null);
46
49
  const paneDef = instance ? pluginRegistry.panes.get(instance.paneId) ?? null : null;
47
50
  const hasPaneSettings = !!instance && pluginRegistry.hasPaneSettings(instance.instanceId);
@@ -171,8 +174,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
171
174
  alignItems="center"
172
175
  flexGrow={1}
173
176
  minWidth={0}
174
- paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}
175
- paddingRight={1}
177
+ paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
178
+ paddingRight={showWindowControls ? 0 : 1}
176
179
  >
177
180
  <Box flexGrow={1} minWidth={0} overflow="hidden">
178
181
  <Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
@@ -189,6 +192,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
189
192
  {" ... "}
190
193
  </Text>
191
194
  )}
195
+ {showWindowControls ? <WindowControls /> : null}
192
196
  </Box>
193
197
  </Box>
194
198
  <PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={background}>
@@ -17,7 +17,8 @@ import { formatPercentRaw } from "../../utils/format";
17
17
  import { formatMarketPrice } from "../../market-data/market/format";
18
18
  import { marketStateLabel, marketStateColor, getActiveQuoteDisplay } from "../../market-data/market/status";
19
19
  import { VERSION } from "../../version";
20
- import { TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
20
+ import { getTitlebarLeadingInset } from "./titlebar-overlay";
21
+ import { WindowControls } from "./window-controls";
21
22
 
22
23
  const SPY_REFRESH_MS = 5 * 60_000; // 5 min
23
24
  const UPDATE_NOTICE_DURATION_MS = 5_000;
@@ -125,7 +126,9 @@ export function Header() {
125
126
  useThemeColors();
126
127
  const baseCurrency = useAppSelector(selectBaseCurrency);
127
128
  const appActive = useAppActive();
128
- const { titleBarOverlay } = useUiCapabilities();
129
+ const { titleBarOverlay, windowControls } = useUiCapabilities();
130
+ const showWindowControls = windowControls === "windows";
131
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
129
132
  const spyQuoteEntry = useQuoteEntry("SPY", null);
130
133
  const spyQuote = useResolvedEntryValue(spyQuoteEntry);
131
134
 
@@ -165,10 +168,10 @@ export function Header() {
165
168
  style={{
166
169
  boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
167
170
  paddingLeft: 8,
168
- paddingRight: 12,
171
+ paddingRight: showWindowControls ? 0 : 12,
169
172
  }}
170
173
  >
171
- <Box paddingLeft={TITLEBAR_TRAFFIC_LIGHT_WIDTH} flexDirection="row" alignItems="center" gap={1}>
174
+ <Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
172
175
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
173
176
  Gloomberb
174
177
  </Text>
@@ -195,7 +198,10 @@ export function Header() {
195
198
  <Box paddingRight={1}>
196
199
  <Text fg={spyColor}>{spyText}</Text>
197
200
  </Box>
198
- <Text fg={colors.headerText}>{baseCurrency}</Text>
201
+ <Box paddingRight={showWindowControls ? 1 : 0}>
202
+ <Text fg={colors.headerText}>{baseCurrency}</Text>
203
+ </Box>
204
+ {showWindowControls ? <WindowControls /> : null}
199
205
  </Box>
200
206
  );
201
207
  }
@@ -209,7 +215,7 @@ export function Header() {
209
215
  data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
210
216
  className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
211
217
  >
212
- <Box paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}>
218
+ <Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
213
219
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
214
220
  Gloomberb v{VERSION}
215
221
  </Text>
@@ -230,6 +236,7 @@ export function Header() {
230
236
  {baseCurrency}
231
237
  </Text>
232
238
  </Box>
239
+ {showWindowControls ? <WindowControls /> : null}
233
240
  </Box>
234
241
  );
235
242
  }
@@ -61,10 +61,11 @@ function createShellPluginRegistry(options?: {
61
61
  } as unknown as PluginRegistry;
62
62
  }
63
63
 
64
- async function emitKeypress(event: { name?: string; sequence?: string; ctrl?: boolean; meta?: boolean; shift?: boolean }) {
64
+ async function emitKeypress(event: { name?: string; sequence?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }) {
65
65
  await act(async () => {
66
66
  const keyEvent = {
67
67
  ctrl: false,
68
+ alt: false,
68
69
  meta: false,
69
70
  option: false,
70
71
  shift: false,
@@ -312,6 +313,7 @@ describe("Shell", () => {
312
313
  const frame = testSetup.captureCharFrame();
313
314
  expect(frame).toContain("Settings");
314
315
  expect(frame).toContain("Ctrl+,");
316
+ expect(frame).not.toContain("Layout Actions");
315
317
  expect(frame).not.toContain("CmdOrCtrl");
316
318
  });
317
319
 
@@ -400,24 +402,92 @@ describe("Shell", () => {
400
402
  });
401
403
  await testSetup.renderOnce();
402
404
 
403
- expect(testSetup.captureCharFrame()).toContain("Dock Pane");
405
+ const frame = testSetup.captureCharFrame();
406
+ expect(frame).toContain("Dock Pane");
407
+ expect(frame).not.toContain("Layout Actions");
404
408
  });
405
409
 
406
410
  test("resolves pane management shortcuts", () => {
407
411
  const base = { ctrl: false, meta: true, super: true, shift: true, alt: false };
408
412
  expect(resolvePaneManagementShortcut({ ...base, name: ",", key: ",", shift: false })).toBe("settings");
409
413
  expect(resolvePaneManagementShortcut({ ...base, name: "w", key: "w", ctrl: true, meta: false, super: false, shift: false })).toBe("close");
414
+ expect(resolvePaneManagementShortcut({ ...base, name: "w", key: "w", shift: false, alt: true })).toBe("close-all-floating");
415
+ expect(resolvePaneManagementShortcut({ ...base, name: "W", key: "W", shift: false })).toBeNull();
410
416
  expect(resolvePaneManagementShortcut({ ...base, name: "D", key: "D" })).toBe("toggle-floating");
411
417
  expect(resolvePaneManagementShortcut({ ...base, name: "o", key: "o" })).toBe("pop-out");
412
418
  expect(resolvePaneManagementShortcut({ ...base, name: "c", key: "c" })).toBe("copy-screenshot");
413
419
  expect(resolvePaneManagementShortcut({ ...base, name: "l", key: "l" })).toBe("layout-actions");
414
420
  expect(resolvePaneManagementShortcut({ ...base, name: "g", key: "g" })).toBe("gridlock-all");
415
421
  expect(resolvePaneManagementShortcut({ ...base, name: "m", key: "m" })).toBe("window-mode");
422
+ expect(resolvePaneManagementShortcut({ ...base, name: "r", key: "r" })).toBe("window-resize-mode");
416
423
  expect(resolvePaneManagementShortcut({ ...base, name: "n", key: "n" })).toBeNull();
417
424
  expect(resolvePaneManagementShortcut({ ...base, name: "d", key: "d", alt: true })).toBeNull();
418
425
  expect(resolvePaneManagementShortcut({ ...base, name: "d", key: "d", meta: false, super: false })).toBeNull();
419
426
  });
420
427
 
428
+ test("exits window mode on Enter without changes", async () => {
429
+ const config = createDefaultConfig("/tmp/gloomberb-shell-window-mode-resize-shortcut-test");
430
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
431
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
432
+ const dockedLayout = {
433
+ dockRoot: {
434
+ kind: "split" as const,
435
+ axis: "horizontal" as const,
436
+ ratio: 0.5,
437
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
438
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
439
+ },
440
+ instances: [{ ...mainPane }, { ...detailPane }],
441
+ floating: [],
442
+ detached: [],
443
+ };
444
+ const { actions } = await renderShellForWindowModeTest(
445
+ createShellStateWithLayout(config, dockedLayout, "portfolio-list:main"),
446
+ );
447
+
448
+ await emitKeypress({ name: "m", ctrl: true, shift: true });
449
+ expect(testSetup.captureCharFrame()).toContain("WINDOW MOVE");
450
+
451
+ await emitKeypress({ name: "enter" });
452
+ await act(async () => {
453
+ await testSetup!.renderOnce();
454
+ });
455
+ expect(testSetup.captureCharFrame()).not.toContain("WINDOW MOVE");
456
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
457
+ });
458
+
459
+ test("starts resize mode directly from the resize shortcut", async () => {
460
+ const config = createDefaultConfig("/tmp/gloomberb-shell-window-mode-resize-shortcut-test");
461
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
462
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
463
+ const dockedLayout = {
464
+ dockRoot: {
465
+ kind: "split" as const,
466
+ axis: "horizontal" as const,
467
+ ratio: 0.5,
468
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
469
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
470
+ },
471
+ instances: [{ ...mainPane }, { ...detailPane }],
472
+ floating: [],
473
+ detached: [],
474
+ };
475
+ const { actions } = await renderShellForWindowModeTest(
476
+ createShellStateWithLayout(config, dockedLayout, "portfolio-list:main"),
477
+ );
478
+
479
+ await emitKeypress({ name: "r", ctrl: true, shift: true });
480
+ expect(testSetup.captureCharFrame()).toContain("WINDOW RESIZE");
481
+
482
+ await emitKeypress({ name: "enter" });
483
+ await act(async () => {
484
+ await testSetup!.renderOnce();
485
+ });
486
+ const frame = testSetup.captureCharFrame();
487
+ expect(frame).not.toContain("WINDOW RESIZE");
488
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
489
+ });
490
+
421
491
  test("moves a floating pane in window mode and commits once", async () => {
422
492
  const config = createDefaultConfig("/tmp/gloomberb-shell-window-mode-test");
423
493
  const floatingLayout = cloneLayout(config.layout);
@@ -785,4 +855,52 @@ describe("Shell", () => {
785
855
  expect(updateLayout?.layout.floating).toEqual([]);
786
856
  });
787
857
 
858
+ test("closes all floating panes with Ctrl+Alt+W", async () => {
859
+ const config = createDefaultConfig("/tmp/gloomberb-shell-test");
860
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
861
+ const firstDetailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
862
+ if (!mainPane || !firstDetailPane) throw new Error("missing default panes");
863
+ const secondDetailPane = {
864
+ ...firstDetailPane,
865
+ instanceId: "ticker-detail:secondary",
866
+ };
867
+
868
+ const mixedLayout = {
869
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
870
+ instances: [{ ...mainPane }, { ...firstDetailPane }, secondDetailPane],
871
+ floating: [
872
+ { instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8 },
873
+ { instanceId: "ticker-detail:secondary", x: 8, y: 3, width: 30, height: 8 },
874
+ ],
875
+ detached: [],
876
+ };
877
+ const state = {
878
+ ...createInitialState({
879
+ ...config,
880
+ layout: cloneLayout(mixedLayout),
881
+ layouts: [{ name: "Default", layout: cloneLayout(mixedLayout) }],
882
+ }),
883
+ focusedPaneId: "portfolio-list:main",
884
+ };
885
+ const actions: Array<any> = [];
886
+
887
+ testSetup = await testRender(
888
+ <AppContext value={{ state, dispatch: (action) => actions.push(action) }}>
889
+ <TestDialogProvider>
890
+ <Shell pluginRegistry={createShellPluginRegistry()} />
891
+ </TestDialogProvider>
892
+ </AppContext>,
893
+ { width: 40, height: 12 },
894
+ );
895
+
896
+ await testSetup.renderOnce();
897
+ await emitKeypress({ name: "w", ctrl: true, alt: true });
898
+
899
+ const updateLayout = actions.find((action) => action.type === "UPDATE_LAYOUT");
900
+ expect(actions).toContainEqual({ type: "PUSH_LAYOUT_HISTORY" });
901
+ expect(updateLayout?.layout.instances.map((instance: { instanceId: string }) => instance.instanceId)).toEqual(["portfolio-list:main"]);
902
+ expect(updateLayout?.layout.floating).toEqual([]);
903
+ expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
904
+ });
905
+
788
906
  });
@@ -186,6 +186,7 @@ export function Shell({
186
186
  });
187
187
 
188
188
  const {
189
+ closeAllFloatingPanes,
189
190
  closeFocusedPane,
190
191
  copyFocusedPaneScreenshot,
191
192
  copyPaneScreenshot,
@@ -213,6 +214,7 @@ export function Shell({
213
214
 
214
215
  useShellPaneManagementShortcuts({
215
216
  cancelActiveDrag,
217
+ closeAllFloatingPanes,
216
218
  closeFocusedPane,
217
219
  copyFocusedPaneScreenshot,
218
220
  focusedPaneId,
@@ -101,15 +101,9 @@ export function menuForPane(
101
101
  {
102
102
  id: "window-resize-mode",
103
103
  label: "Resize Window...",
104
- accelerator: "WIN resize",
104
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.windowResizeMode,
105
105
  onSelect: () => pluginRegistry.openWindowMode(pane.instance.instanceId, "resize"),
106
106
  },
107
- {
108
- id: "layout-actions",
109
- label: "Layout Actions...",
110
- accelerator: PANE_MANAGEMENT_ACCELERATORS.layoutActions,
111
- onSelect: () => pluginRegistry.openCommandBar("LAY "),
112
- },
113
107
  );
114
108
 
115
109
  return baseActions;
@@ -5,6 +5,7 @@ import {
5
5
  floatPane,
6
6
  gridlockAllPanes,
7
7
  isPaneInLayout,
8
+ removeFloatingPanes,
8
9
  removePane,
9
10
  type ResolvedPane,
10
11
  } from "../../../../plugins/pane-manager";
@@ -73,6 +74,12 @@ export function useShellPaneActions({
73
74
  return true;
74
75
  }, [focusedPaneId, persistLayout, visibleLayout]);
75
76
 
77
+ const closeAllFloatingPanes = useCallback(() => {
78
+ if (visibleLayout.floating.length === 0) return false;
79
+ persistLayout(removeFloatingPanes(visibleLayout));
80
+ return true;
81
+ }, [persistLayout, visibleLayout]);
82
+
76
83
  const copyFocusedPaneScreenshot = useCallback(() => {
77
84
  if (!focusedPaneId || !nativePaneChrome || !rendererHost.copyPngImage) return false;
78
85
  if (!paneMap.has(focusedPaneId)) return false;
@@ -115,6 +122,7 @@ export function useShellPaneActions({
115
122
  }, [persistLayout, visibleLayout]);
116
123
 
117
124
  return {
125
+ closeAllFloatingPanes,
118
126
  closeFocusedPane,
119
127
  copyFocusedPaneScreenshot,
120
128
  copyPaneScreenshot,