gloomberb 0.9.1 → 0.9.3

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 (136) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  29. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  30. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  31. package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
  32. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  33. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  34. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  35. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  36. package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
  37. package/src/components/chart/core/pane-settings.test.ts +27 -0
  38. package/src/components/chart/core/pane-settings.ts +60 -1
  39. package/src/components/chart/core/pointer.ts +2 -0
  40. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  41. package/src/components/chart/stock-chart/settings.ts +7 -3
  42. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  43. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  44. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  45. package/src/components/data-table/view.tsx +13 -2
  46. package/src/components/feed-data-table/stack-view.tsx +9 -1
  47. package/src/components/index.ts +1 -1
  48. package/src/components/input-search-bar.test.tsx +103 -0
  49. package/src/components/input-search-bar.tsx +16 -0
  50. package/src/components/layout/detached-pane-shell.tsx +5 -2
  51. package/src/components/layout/header.tsx +13 -5
  52. package/src/components/layout/shell/active-drag.ts +5 -1
  53. package/src/components/layout/shell/drag/runtime.ts +21 -3
  54. package/src/components/layout/shell/fullscreen.ts +10 -0
  55. package/src/components/layout/shell/index.test.tsx +395 -2
  56. package/src/components/layout/shell/index.tsx +128 -2
  57. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  58. package/src/components/layout/shell/pane/layers.tsx +26 -14
  59. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  60. package/src/components/layout/shell/shortcuts.ts +4 -0
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  62. package/src/components/layout/shell/window-mode/index.ts +11 -3
  63. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  64. package/src/components/layout/status-bar.test.tsx +105 -0
  65. package/src/components/layout/status-bar.tsx +32 -6
  66. package/src/components/layout/transient-layout.tsx +57 -0
  67. package/src/components/layout/window-controls.tsx +19 -10
  68. package/src/components/layout/window-edit/mode.test.ts +76 -5
  69. package/src/components/layout/window-edit/mode.ts +47 -2
  70. package/src/components/layout/window-edit/presentation.ts +2 -1
  71. package/src/components/price-performance.tsx +183 -0
  72. package/src/core/app-services.ts +7 -4
  73. package/src/data/config/store/index.ts +1 -2
  74. package/src/market-data/performance.test.ts +54 -0
  75. package/src/market-data/performance.ts +109 -0
  76. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  77. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  78. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  79. package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
  80. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  81. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  82. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  83. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  84. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  85. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  86. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  87. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  88. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  89. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  90. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  91. package/src/plugins/builtin/shared/read-state.ts +43 -0
  92. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  93. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  94. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  95. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  96. package/src/plugins/builtin/substack/pane.tsx +8 -3
  97. package/src/plugins/builtin/substack/read-state.ts +55 -0
  98. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  99. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  100. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  102. package/src/plugins/pane-manager/docking.ts +9 -2
  103. package/src/plugins/pane-manager/queries.ts +8 -2
  104. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  105. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  106. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  107. package/src/plugins/prediction-markets/pane.tsx +21 -1
  108. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  109. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  110. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  111. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  112. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  113. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  114. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  115. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  116. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  117. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  118. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  119. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  120. package/src/renderers/electrobun/bun/index.ts +22 -2
  121. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  122. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  123. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  124. package/src/renderers/electrobun/view/app-services.ts +6 -4
  125. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  126. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  127. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  128. package/src/renderers/electrobun/view/main.tsx +2 -1
  129. package/src/renderers/electrobun/view/styles.css +9 -8
  130. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  131. package/src/renderers/opentui/start.tsx +13 -5
  132. package/src/state/app/input-capture.ts +12 -0
  133. package/src/types/plugin.ts +28 -0
  134. package/src/utils/cli-output.ts +6 -0
  135. package/src/utils/search-focus-navigation.ts +24 -0
  136. package/src/version.ts +1 -1
@@ -117,6 +117,42 @@ function registerOptionalTextPane(pluginRegistry: MutablePaneRegistry): void {
117
117
  });
118
118
  }
119
119
 
120
+ function registerQueryOnlyPane(pluginRegistry: MutablePaneRegistry): void {
121
+ mutableRegistryMap(pluginRegistry.panes).set("prediction-markets", {
122
+ id: "prediction-markets",
123
+ name: "Prediction Markets",
124
+ component: () => null,
125
+ defaultPosition: "left",
126
+ defaultMode: "floating",
127
+ });
128
+ mutableRegistryMap(pluginRegistry.paneTemplates).set("new-prediction-markets-pane", {
129
+ id: "new-prediction-markets-pane",
130
+ paneId: "prediction-markets",
131
+ label: "Prediction Markets",
132
+ description: "Open a new prediction markets browser pane",
133
+ keywords: ["prediction", "markets", "polymarket", "kalshi", "events"],
134
+ shortcut: { prefix: "PM", argPlaceholder: "query", argKind: "text" },
135
+ });
136
+ }
137
+
138
+ function registerShortcutOnlyPane(pluginRegistry: MutablePaneRegistry): void {
139
+ mutableRegistryMap(pluginRegistry.panes).set("top-news", {
140
+ id: "top-news",
141
+ name: "Top News",
142
+ component: () => null,
143
+ defaultPosition: "right",
144
+ defaultMode: "floating",
145
+ });
146
+ mutableRegistryMap(pluginRegistry.paneTemplates).set("top-news-pane", {
147
+ id: "top-news-pane",
148
+ paneId: "top-news",
149
+ label: "Top News",
150
+ description: "Curated top market stories ranked by importance",
151
+ keywords: ["top", "news", "headlines", "stories"],
152
+ shortcut: { prefix: "TOP" },
153
+ });
154
+ }
155
+
120
156
  describe("CommandBar pane and layout routes", () => {
121
157
  const layoutModeConfig = (config: AppConfig): AppConfig => {
122
158
  const research = cloneLayout(config.layout);
@@ -271,6 +307,46 @@ describe("CommandBar pane and layout routes", () => {
271
307
  expect(testSetup.captureCharFrame()).not.toContain("Create Pane");
272
308
  });
273
309
 
310
+ for (const scenario of [
311
+ {
312
+ query: "prediction markets",
313
+ register: registerQueryOnlyPane,
314
+ templateId: "new-prediction-markets-pane",
315
+ },
316
+ {
317
+ query: "top news",
318
+ register: registerShortcutOnlyPane,
319
+ templateId: "top-news-pane",
320
+ },
321
+ ] as const) {
322
+ test(`creates ${scenario.templateId} directly when it has no config fields`, async () => {
323
+ const created: CreatedPaneCall[] = [];
324
+
325
+ testSetup = await testRender(<CommandBarHarness
326
+ query={scenario.query}
327
+ live
328
+ configurePluginRegistry={(pluginRegistry) => {
329
+ scenario.register(pluginRegistry);
330
+ recordPaneCreations(pluginRegistry, created);
331
+ }}
332
+ />, {
333
+ width: 100,
334
+ height: 18,
335
+ });
336
+
337
+ await testSetup.renderOnce();
338
+
339
+ await act(async () => {
340
+ testSetup!.mockInput.pressEnter();
341
+ await Bun.sleep(0);
342
+ await testSetup!.renderOnce();
343
+ });
344
+
345
+ expect(created).toEqual([{ templateId: scenario.templateId, options: undefined }]);
346
+ expect(testSetup.captureCharFrame()).not.toContain("Create Pane");
347
+ });
348
+ }
349
+
274
350
  test("shows pane templates that share the same shortcut", async () => {
275
351
  testSetup = await testRender(<CommandBarHarness
276
352
  query="DUP"
@@ -26,6 +26,11 @@ export type DataTableKeyEvent = TableViewKeyEvent;
26
26
 
27
27
  const DATA_TABLE_SELECTION_COMMIT_DELAY_MS = 150;
28
28
 
29
+ export interface DataTableRootKeyContext {
30
+ selectedIndex: number;
31
+ itemCount: number;
32
+ }
33
+
29
34
  export type DataTableSelectionChangeReason =
30
35
  | "keyboard"
31
36
  | "pointer"
@@ -95,7 +100,10 @@ export interface DataTableViewProps<
95
100
  hoveredIdx?: number | null;
96
101
  setHoveredIdx?: (index: number | null) => void;
97
102
  keyboardNavigation?: boolean;
98
- onRootKeyDown?: (event: DataTableKeyEvent) => boolean | void;
103
+ onRootKeyDown?: (
104
+ event: DataTableKeyEvent,
105
+ context: DataTableRootKeyContext,
106
+ ) => boolean | void;
99
107
  resetScrollKey?: unknown;
100
108
  }
101
109
 
@@ -486,7 +494,10 @@ export function DataTableView<
486
494
  if (event.defaultPrevented || event.propagationStopped) return;
487
495
  if (!focused || !keyboardNavigation) return;
488
496
 
489
- if (onRootKeyDown?.(event)) return;
497
+ if (onRootKeyDown?.(event, {
498
+ selectedIndex: effectiveSelectedIndexRef.current,
499
+ itemCount: tableProps.items.length,
500
+ })) return;
490
501
  if (tableProps.items.length === 0) return;
491
502
 
492
503
  if (isNextTableRowKey(event)) {
@@ -6,6 +6,7 @@ import { formatTimeAgo } from "../../utils/format";
6
6
  import { isPlainKey } from "../../utils/keyboard";
7
7
  import { toTimestampMillis } from "../../utils/timestamp";
8
8
  import { DataTableStackView } from "../data-table/stack-view";
9
+ import type { DataTableRootKeyContext } from "../data-table/view";
9
10
  import {
10
11
  activeStackIndex,
11
12
  sortIndexedStackRows,
@@ -45,9 +46,16 @@ interface FeedDataTableStackViewProps {
45
46
  rootAfter?: ReactNode;
46
47
  onRootKeyDown?: (event: {
47
48
  name?: string;
49
+ sequence?: string;
50
+ ctrl?: boolean;
51
+ meta?: boolean;
52
+ super?: boolean;
53
+ alt?: boolean;
54
+ option?: boolean;
55
+ shift?: boolean;
48
56
  preventDefault?: () => void;
49
57
  stopPropagation?: () => void;
50
- }) => boolean | void;
58
+ }, context: DataTableRootKeyContext) => boolean | void;
51
59
  sourceLabel?: string;
52
60
  titleLabel?: string;
53
61
  emptyStateTitle?: string;
@@ -8,7 +8,7 @@ export type { TickerListVisibleRange } from "./ticker/list-table-view";
8
8
  export { TickerBadgeList } from "./ticker/badge/list";
9
9
  export { InputSearchBar } from "./input-search-bar";
10
10
  export { DataTableView } from "./data-table/view";
11
- export type { DataTableKeyEvent } from "./data-table/view";
11
+ export type { DataTableKeyEvent, DataTableRootKeyContext } from "./data-table/view";
12
12
  export { DataTableStackView } from "./data-table/stack-view";
13
13
  export { FeedDataTableStackView } from "./feed-data-table/stack-view";
14
14
  export type { FeedDataTableItem } from "./feed-data-table/stack-view";
@@ -0,0 +1,103 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
3
+ import { testRender } from "../renderers/opentui/test-utils";
4
+ import { AppContext, createInitialState, type AppAction } from "../state/app/context";
5
+ import { createDefaultConfig } from "../types/config";
6
+ import type { InputRenderable } from "../ui";
7
+ import { InputSearchBar } from "./input-search-bar";
8
+
9
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
+ let setSearchActive: Dispatch<SetStateAction<boolean>> | null = null;
11
+
12
+ afterEach(async () => {
13
+ setSearchActive = null;
14
+ if (!testSetup) return;
15
+ await act(async () => {
16
+ testSetup!.renderer.destroy();
17
+ });
18
+ testSetup = undefined;
19
+ });
20
+
21
+ function Harness({
22
+ actions,
23
+ onNavigateDown,
24
+ }: {
25
+ actions: AppAction[];
26
+ onNavigateDown?: () => void;
27
+ }) {
28
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-input-search-bar"));
29
+ const inputRef = useRef<InputRenderable | null>(null);
30
+ const [active, setActive] = useState(true);
31
+ setSearchActive = setActive;
32
+ const dispatch = useCallback((action: AppAction) => {
33
+ actions.push(action);
34
+ }, [actions]);
35
+
36
+ return (
37
+ <AppContext value={{ state, dispatch }}>
38
+ <InputSearchBar
39
+ value=""
40
+ focused
41
+ active={active}
42
+ width={30}
43
+ focusToken={0}
44
+ inputRef={inputRef}
45
+ placeholder="search"
46
+ debounceMs={100}
47
+ onNavigateDown={onNavigateDown}
48
+ onFocus={() => {}}
49
+ onBlur={() => {}}
50
+ onQueryChange={() => {}}
51
+ />
52
+ </AppContext>
53
+ );
54
+ }
55
+
56
+ describe("InputSearchBar", () => {
57
+ test("captures app input while the search input is active", async () => {
58
+ const actions: AppAction[] = [];
59
+
60
+ testSetup = await testRender(<Harness actions={actions} />, { width: 40, height: 4 });
61
+ await act(async () => {
62
+ await testSetup!.renderOnce();
63
+ });
64
+
65
+ expect(actions).toEqual([{ type: "SET_INPUT_CAPTURED", captured: true }]);
66
+ if (!setSearchActive) throw new Error("search setter was not registered");
67
+
68
+ await act(async () => {
69
+ setSearchActive(false);
70
+ await testSetup!.renderOnce();
71
+ });
72
+
73
+ expect(actions).toEqual([
74
+ { type: "SET_INPUT_CAPTURED", captured: true },
75
+ { type: "SET_INPUT_CAPTURED", captured: false },
76
+ ]);
77
+ });
78
+
79
+ test("moves from the active search input to the table with Down", async () => {
80
+ const actions: AppAction[] = [];
81
+ let navigatedDown = 0;
82
+
83
+ testSetup = await testRender(
84
+ <Harness
85
+ actions={actions}
86
+ onNavigateDown={() => {
87
+ navigatedDown += 1;
88
+ }}
89
+ />,
90
+ { width: 40, height: 4 },
91
+ );
92
+ await act(async () => {
93
+ await testSetup!.renderOnce();
94
+ });
95
+
96
+ await act(async () => {
97
+ testSetup!.mockInput.pressArrow("down");
98
+ await testSetup!.renderOnce();
99
+ });
100
+
101
+ expect(navigatedDown).toBe(1);
102
+ });
103
+ });
@@ -1,6 +1,9 @@
1
1
  import { useCallback, useEffect, useState, type RefObject } from "react";
2
2
  import { Box, Input, Text, type InputRenderable } from "../ui";
3
3
  import { colors } from "../theme/colors";
4
+ import { useAppInputCapture } from "../state/app/input-capture";
5
+ import { useShortcut } from "../react/input";
6
+ import { isPlainArrowDown, stopSearchFocusNavigation } from "../utils/search-focus-navigation";
4
7
 
5
8
  export function InputSearchBar({
6
9
  value,
@@ -12,6 +15,7 @@ export function InputSearchBar({
12
15
  placeholder,
13
16
  debounceMs,
14
17
  normalizeValue = identity,
18
+ onNavigateDown,
15
19
  onFocus,
16
20
  onBlur,
17
21
  onQueryChange,
@@ -25,11 +29,23 @@ export function InputSearchBar({
25
29
  placeholder: string;
26
30
  debounceMs: number;
27
31
  normalizeValue?: (value: string) => string;
32
+ onNavigateDown?: () => void;
28
33
  onFocus: () => void;
29
34
  onBlur: () => void;
30
35
  onQueryChange: (query: string) => void;
31
36
  }) {
32
37
  const [draft, setDraft] = useState(value);
38
+ useAppInputCapture(focused && active);
39
+
40
+ useShortcut((event) => {
41
+ if (!onNavigateDown || !isPlainArrowDown(event)) return;
42
+ stopSearchFocusNavigation(event);
43
+ onNavigateDown();
44
+ }, {
45
+ allowEditable: true,
46
+ enabled: focused && active && !!onNavigateDown,
47
+ phase: "before",
48
+ });
33
49
 
34
50
  useEffect(() => {
35
51
  setDraft(value);
@@ -13,7 +13,7 @@ import { PaneContent } from "./pane/content";
13
13
  import { resolvePaneBodyFrame } from "./pane/sizing";
14
14
  import { getPaneDisplayTitle } from "./pane/title";
15
15
  import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
16
- import { WindowControls } from "./window-controls";
16
+ import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
17
17
  import {
18
18
  createDoubleEscapeCloseState,
19
19
  recordDoubleEscapeClose,
@@ -174,8 +174,10 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
174
174
  alignItems="center"
175
175
  flexGrow={1}
176
176
  minWidth={0}
177
+ backgroundColor={titleBackground}
177
178
  paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
178
179
  paddingRight={showWindowControls ? 0 : 1}
180
+ style={{ position: "relative" }}
179
181
  >
180
182
  <Box flexGrow={1} minWidth={0} overflow="hidden">
181
183
  <Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
@@ -192,7 +194,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
192
194
  {" ... "}
193
195
  </Text>
194
196
  )}
195
- {showWindowControls ? <WindowControls /> : null}
197
+ {showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
198
+ {showWindowControls ? <WindowControls windowKind="detached" /> : null}
196
199
  </Box>
197
200
  </Box>
198
201
  <PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={background}>
@@ -1,5 +1,5 @@
1
- import { Box, SpinnerMark, Text, TextAttributes, useUiCapabilities } from "../../ui";
2
- import { useEffect, type ReactNode } from "react";
1
+ import { Box, SpinnerMark, Text, TextAttributes, useRendererHost, useUiCapabilities } from "../../ui";
2
+ import { useCallback, useEffect, type ReactNode } from "react";
3
3
  import { blendHex, colors, priceColor } from "../../theme/colors";
4
4
  import { useThemeColors } from "../../theme/theme-context";
5
5
  import { useAppActive } from "../../state/app/activity";
@@ -18,7 +18,7 @@ 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
20
  import { getTitlebarLeadingInset } from "./titlebar-overlay";
21
- import { WindowControls } from "./window-controls";
21
+ import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
22
22
 
23
23
  const SPY_REFRESH_MS = 5 * 60_000; // 5 min
24
24
  const UPDATE_NOTICE_DURATION_MS = 5_000;
@@ -124,6 +124,7 @@ function UpdateStatus() {
124
124
 
125
125
  export function Header() {
126
126
  useThemeColors();
127
+ const rendererHost = useRendererHost();
127
128
  const baseCurrency = useAppSelector(selectBaseCurrency);
128
129
  const appActive = useAppActive();
129
130
  const { titleBarOverlay, windowControls } = useUiCapabilities();
@@ -150,6 +151,11 @@ export function Header() {
150
151
  ? `SPY ${formatMarketPrice(activeSpyQuote.price, { assetCategory: "ETF" })} ${formatPercentRaw(activeSpyQuote.changePercent)}`
151
152
  : "SPY —";
152
153
 
154
+ const startWindowDrag = useCallback(() => {
155
+ if (!titleBarOverlay) return;
156
+ void rendererHost.startWindowDrag?.();
157
+ }, [rendererHost, titleBarOverlay]);
158
+
153
159
  // Market status
154
160
  const mktState = spyQuote?.marketState;
155
161
  const mktLabel = mktState ? marketStateLabel(mktState) : "";
@@ -164,11 +170,12 @@ export function Header() {
164
170
  backgroundColor={colors.header}
165
171
  data-gloom-role="app-header"
166
172
  data-titlebar-overlay="true"
167
- className="electrobun-webkit-app-region-drag"
173
+ onMouseDown={startWindowDrag}
168
174
  style={{
169
175
  boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
170
176
  paddingLeft: 8,
171
177
  paddingRight: showWindowControls ? 0 : 12,
178
+ position: "relative",
172
179
  }}
173
180
  >
174
181
  <Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
@@ -201,6 +208,7 @@ export function Header() {
201
208
  <Box paddingRight={showWindowControls ? 1 : 0}>
202
209
  <Text fg={colors.headerText}>{baseCurrency}</Text>
203
210
  </Box>
211
+ {showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
204
212
  {showWindowControls ? <WindowControls /> : null}
205
213
  </Box>
206
214
  );
@@ -213,7 +221,7 @@ export function Header() {
213
221
  backgroundColor={colors.header}
214
222
  data-gloom-role="app-header"
215
223
  data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
216
- className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
224
+ onMouseDown={startWindowDrag}
217
225
  >
218
226
  <Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
219
227
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
@@ -4,6 +4,7 @@ import {
4
4
  getRememberedFloatingRect,
5
5
  resizeSplitAtPath,
6
6
  simulateDrop,
7
+ type DockGeometryOptions,
7
8
  type DockLeafLayout,
8
9
  type DropTarget,
9
10
  type LayoutBounds,
@@ -32,6 +33,7 @@ interface UseShellActiveDragOptions {
32
33
  bounds: LayoutBounds;
33
34
  contentHeight: number;
34
35
  dispatch: Dispatch<AppAction>;
36
+ dockGeometryOptions: DockGeometryOptions;
35
37
  dockLeafLayouts: DockLeafLayout[];
36
38
  dragRuntime: ShellDragRuntimeState;
37
39
  focusPane: (paneId: string) => void;
@@ -51,6 +53,7 @@ export function useShellActiveDrag({
51
53
  bounds,
52
54
  contentHeight,
53
55
  dispatch,
56
+ dockGeometryOptions,
54
57
  dockLeafLayouts,
55
58
  dragRuntime,
56
59
  focusPane,
@@ -117,7 +120,7 @@ export function useShellActiveDrag({
117
120
  const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, hitX, hitShellY));
118
121
  if (hoveredCell) {
119
122
  const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
120
- const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds);
123
+ const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds, dockGeometryOptions);
121
124
  if (simulation.previewRect) {
122
125
  updateDockPreview({ kind: "dock", target, rect: simulation.previewRect });
123
126
  } else {
@@ -199,6 +202,7 @@ export function useShellActiveDrag({
199
202
  bounds,
200
203
  contentHeight,
201
204
  dispatch,
205
+ dockGeometryOptions,
202
206
  dividerPreviewRef,
203
207
  dockLeafLayouts,
204
208
  dockPreviewRef,
@@ -6,8 +6,10 @@ import {
6
6
  type MutableRefObject,
7
7
  type SetStateAction,
8
8
  } from "react";
9
+ import { useRafCallback } from "../../../../react/use-raf-callback";
9
10
  import {
10
11
  type DockDividerLayout,
12
+ type DockGeometryOptions,
11
13
  type DockLeafLayout,
12
14
  type FloatingRect,
13
15
  type LayoutBounds,
@@ -91,17 +93,26 @@ export function useShellDragRuntimeState({
91
93
  }): ShellDragRuntimeState {
92
94
  const dragRef = useRef<DragMode | null>(null);
93
95
  const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
96
+ const pendingDragFloatingRectRef = useRef<{ paneId: string; rect: FloatingRect } | null>(null);
94
97
  const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
95
98
  const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
96
99
  const [dockPreview, setDockPreview] = useState<DragPreview | null>(null);
97
100
  const dividerPreviewRef = useRef<DividerPreviewState | null>(null);
98
101
  const dockPreviewRef = useRef<DragPreview | null>(null);
102
+ const flushDragFloatingRect = useRafCallback(() => {
103
+ setDragFloatingRect(pendingDragFloatingRectRef.current);
104
+ });
99
105
 
100
106
  const updateDragFloatingRect = useCallback((next: { paneId: string; rect: FloatingRect } | null) => {
101
- setDragFloatingRect(next
107
+ pendingDragFloatingRectRef.current = next
102
108
  ? { paneId: next.paneId, rect: constrainFloatingRectToBounds(next.rect, width, contentHeight) }
103
- : null);
104
- }, [contentHeight, width]);
109
+ : null;
110
+ if (!next) {
111
+ setDragFloatingRect(null);
112
+ return;
113
+ }
114
+ flushDragFloatingRect();
115
+ }, [contentHeight, flushDragFloatingRect, width]);
105
116
 
106
117
  const updateDividerPreview = useCallback((next: DividerPreviewState | null) => {
107
118
  dividerPreviewRef.current = next;
@@ -146,6 +157,7 @@ interface UseShellPointerRuntimeOptions {
146
157
  closePaneMenu: () => void;
147
158
  contentHeight: number;
148
159
  dispatch: Dispatch<AppAction>;
160
+ dockGeometryOptions: DockGeometryOptions;
149
161
  dockDividerLayouts: DockDividerLayout[];
150
162
  dockLeafLayouts: DockLeafLayout[];
151
163
  dragRuntime: ShellDragRuntimeState;
@@ -166,6 +178,7 @@ interface UseShellPointerRuntimeOptions {
166
178
  setHoveredMenuItemId: Dispatch<SetStateAction<string | null>>;
167
179
  setMenuState: Dispatch<SetStateAction<ActionMenuState | null>>;
168
180
  snapGuides: ReturnType<typeof makeSnapGuides>;
181
+ transientFocusActive: boolean;
169
182
  updateWindowModePreviewLayout: (nextLayout: LayoutConfig, paneId?: string) => void;
170
183
  visibleFloatingPanes: VisibleFloatingPane[];
171
184
  visibleLayout: LayoutConfig;
@@ -179,6 +192,7 @@ export function useShellPointerRuntime({
179
192
  closePaneMenu,
180
193
  contentHeight,
181
194
  dispatch,
195
+ dockGeometryOptions,
182
196
  dockDividerLayouts,
183
197
  dockLeafLayouts,
184
198
  dragRuntime,
@@ -195,6 +209,7 @@ export function useShellPointerRuntime({
195
209
  setHoveredMenuItemId,
196
210
  setMenuState,
197
211
  snapGuides,
212
+ transientFocusActive,
198
213
  updateWindowModePreviewLayout,
199
214
  visibleFloatingPanes,
200
215
  visibleLayout,
@@ -206,6 +221,7 @@ export function useShellPointerRuntime({
206
221
  bounds,
207
222
  contentHeight,
208
223
  dispatch,
224
+ dockGeometryOptions,
209
225
  dockLeafLayouts,
210
226
  focusPane,
211
227
  dragRuntime,
@@ -237,6 +253,7 @@ export function useShellPointerRuntime({
237
253
  selectWindowModePane,
238
254
  setHoveredMenuItemId,
239
255
  setMenuState,
256
+ transientFocusActive,
240
257
  visibleFloatingPanes,
241
258
  width,
242
259
  windowMode,
@@ -254,6 +271,7 @@ export function useShellPointerRuntime({
254
271
  selectWindowModePane,
255
272
  setHoveredMenuItemId,
256
273
  setMenuState,
274
+ transientFocusActive,
257
275
  windowMode,
258
276
  });
259
277
 
@@ -0,0 +1,10 @@
1
+ import { isPaneInLayout } from "../../../plugins/pane-manager";
2
+ import { cloneLayout, type LayoutConfig } from "../../../types/config";
3
+
4
+ export function resolvePaneFocusSourceLayout(
5
+ layout: LayoutConfig,
6
+ paneId: string | null,
7
+ ): LayoutConfig | null {
8
+ if (!paneId || !isPaneInLayout(layout, paneId)) return null;
9
+ return cloneLayout(layout);
10
+ }