gloomberb 0.9.3 → 0.9.4

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 (137) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app/runtime/desktop-menu.ts +5 -1
  4. package/src/app/runtime/ticker-refresh.ts +4 -1
  5. package/src/app.tsx +10 -1
  6. package/src/brokers/account-cache.ts +1 -1
  7. package/src/brokers/sync-broker-instance.test.ts +107 -0
  8. package/src/brokers/sync-broker-instance.ts +22 -10
  9. package/src/cli/desktop-pane-shot.ts +2 -1
  10. package/src/cli/pane-functions/screenshot.ts +13 -1
  11. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  12. package/src/components/chart/bar-chart-renderer.ts +226 -27
  13. package/src/components/chart/core/pointer.ts +32 -6
  14. package/src/components/chart/price-axis-labels.tsx +3 -1
  15. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  16. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  17. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  18. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  19. package/src/components/chart/static/chart/surface.tsx +167 -24
  20. package/src/components/chart/stock-chart/index.test.ts +25 -0
  21. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  22. package/src/components/layout/detached-pane-shell.tsx +6 -3
  23. package/src/components/layout/floating-pane.tsx +3 -2
  24. package/src/components/layout/pane/content.tsx +20 -8
  25. package/src/components/layout/pane/footer/registration.tsx +6 -2
  26. package/src/components/layout/pane/frame.tsx +4 -4
  27. package/src/components/layout/pane/header.tsx +40 -4
  28. package/src/components/layout/pane/index.tsx +3 -1
  29. package/src/components/layout/pane/sizing.test.ts +43 -0
  30. package/src/components/layout/pane/sizing.ts +13 -4
  31. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  32. package/src/components/layout/shell/drag/runtime.ts +9 -2
  33. package/src/components/layout/shell/index.test.tsx +92 -2
  34. package/src/components/layout/shell/index.tsx +37 -2
  35. package/src/components/layout/shell/layout-state.test.ts +33 -0
  36. package/src/components/layout/shell/layout-state.ts +4 -9
  37. package/src/components/layout/shell/pane/layers.tsx +8 -2
  38. package/src/components/layout/shell/visible-layout.ts +17 -0
  39. package/src/components/layout/status-bar.test.tsx +6 -1
  40. package/src/components/layout/status-bar.tsx +5 -1
  41. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  42. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  43. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  44. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  45. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  46. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  47. package/src/components/table-view-shared.tsx +12 -4
  48. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  49. package/src/components/ui/data-table/types.ts +4 -0
  50. package/src/components/ui/fields.tsx +10 -3
  51. package/src/components/ui/page-stack-view.tsx +6 -2
  52. package/src/components/ui/table-layout.test.ts +25 -0
  53. package/src/components/ui/table-layout.ts +22 -6
  54. package/src/components/ui/tabs.tsx +26 -10
  55. package/src/components/ui/ui.test.tsx +87 -0
  56. package/src/core/state/app/layout-reducer.ts +2 -2
  57. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  58. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  59. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  60. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  61. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  62. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  63. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  64. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  65. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  66. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  67. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  68. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  69. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  70. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  71. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  72. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  73. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  74. package/src/plugins/builtin/options/view.test.tsx +141 -4
  75. package/src/plugins/builtin/options/view.tsx +76 -15
  76. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  77. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  78. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  79. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  80. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  81. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  83. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  84. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  85. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  86. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  87. package/src/plugins/catalog-ui.ts +2 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  89. package/src/plugins/ibkr/config.test.ts +31 -2
  90. package/src/plugins/ibkr/config.ts +9 -2
  91. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  92. package/src/plugins/ibkr/flex/index.ts +30 -0
  93. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  94. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  95. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  96. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  97. package/src/plugins/pane-manager/gridlock.ts +13 -5
  98. package/src/plugins/pane-manager/layout-state.ts +25 -0
  99. package/src/plugins/pane-manager.test.ts +33 -0
  100. package/src/plugins/pane-manager.ts +6 -1
  101. package/src/plugins/prediction-markets/chart.tsx +10 -4
  102. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  103. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  104. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  105. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  106. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  107. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  108. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  109. package/src/plugins/prediction-markets/pane.tsx +50 -22
  110. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  111. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  112. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  113. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  114. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  115. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  116. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  117. package/src/renderers/electrobun/view/styles.css +11 -0
  118. package/src/renderers/opentui/host.tsx +17 -1
  119. package/src/renderers/opentui/ui-host.tsx +57 -25
  120. package/src/sources/provider-router/batches.ts +16 -2
  121. package/src/sources/provider-router/financial-routes.ts +5 -0
  122. package/src/sources/provider-router/financials.ts +11 -23
  123. package/src/sources/provider-router/index.test.ts +189 -0
  124. package/src/sources/provider-router/primary.ts +3 -2
  125. package/src/sources/sec-edgar.test.ts +83 -0
  126. package/src/sources/sec-edgar.ts +231 -0
  127. package/src/sources/yahoo-finance.test.ts +51 -0
  128. package/src/sources/yahoo-finance.ts +49 -1
  129. package/src/state/app/context/index.test.tsx +5 -8
  130. package/src/types/broker.ts +1 -0
  131. package/src/types/config.ts +10 -0
  132. package/src/types/trading.ts +2 -0
  133. package/src/ui/host.tsx +16 -0
  134. package/src/ui/index.tsx +2 -0
  135. package/src/utils/financial-statements.ts +23 -0
  136. package/src/version.ts +1 -1
  137. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -4,28 +4,35 @@ const PANE_FOOTER_ROWS = 1;
4
4
 
5
5
  const NATIVE_PANE_BODY_LAYOUT_PROPS = {
6
6
  flexGrow: 1,
7
+ flexShrink: 1,
7
8
  flexBasis: 0,
9
+ minWidth: 0,
8
10
  minHeight: 0,
9
11
  } as const;
10
12
 
11
- export function shouldReservePaneFooter(nativePaneChrome: boolean | undefined, showFooter: boolean): boolean {
12
- return !nativePaneChrome || showFooter;
13
+ export function shouldReservePaneFooter(nativePaneChrome: boolean | undefined, _showFooter: boolean): boolean {
14
+ return !nativePaneChrome;
13
15
  }
14
16
 
15
17
  function resolvePaneBodyHeight({
16
18
  height,
17
19
  nativePaneChrome,
20
+ footerVisible,
18
21
  reserveFooter = true,
19
22
  headerRows = PANE_HEADER_ROWS,
20
23
  }: {
21
24
  height: number;
22
25
  nativePaneChrome?: boolean;
26
+ footerVisible?: boolean;
23
27
  reserveFooter?: boolean;
24
28
  headerRows?: number;
25
29
  }): number {
26
30
  const finiteHeight = Number.isFinite(height) ? height : 1;
27
31
  const normalizedHeight = nativePaneChrome ? finiteHeight : Math.max(1, Math.floor(finiteHeight));
28
- return Math.max(1, normalizedHeight - headerRows - (reserveFooter ? PANE_FOOTER_ROWS : 0));
32
+ const footerRows = nativePaneChrome
33
+ ? footerVisible ? PANE_FOOTER_ROWS : 0
34
+ : reserveFooter ? PANE_FOOTER_ROWS : 0;
35
+ return Math.max(1, normalizedHeight - headerRows - footerRows);
29
36
  }
30
37
 
31
38
  function getPaneBodyLayoutProps(nativePaneChrome: boolean | undefined, bodyHeight: number | undefined) {
@@ -41,17 +48,19 @@ export function resolvePaneBodyFrame({
41
48
  width,
42
49
  height,
43
50
  nativePaneChrome,
51
+ footerVisible,
44
52
  reserveFooter = true,
45
53
  headerRows = PANE_HEADER_ROWS,
46
54
  }: {
47
55
  width?: number;
48
56
  height?: number;
49
57
  nativePaneChrome?: boolean;
58
+ footerVisible?: boolean;
50
59
  reserveFooter?: boolean;
51
60
  headerRows?: number;
52
61
  }) {
53
62
  const bodyHeight = typeof height === "number"
54
- ? resolvePaneBodyHeight({ height, nativePaneChrome, reserveFooter, headerRows })
63
+ ? resolvePaneBodyHeight({ height, nativePaneChrome, footerVisible, reserveFooter, headerRows })
55
64
  : undefined;
56
65
  return {
57
66
  width: typeof width === "number" ? resolvePaneBodyWidth(width, nativePaneChrome) : undefined,
@@ -0,0 +1,196 @@
1
+ import { useEffect, useRef, type RefObject } from "react";
2
+ import { useNativeRenderer, type NativeCursorState, type NativePostProcessFn } from "../../../ui";
3
+ import type { FloatingRect, LayoutBounds, ResolvedPane } from "../../../plugins/pane-manager";
4
+ import { constrainFloatingRectToBounds } from "./drag";
5
+
6
+ interface VisibleFloatingPane {
7
+ pane: ResolvedPane;
8
+ rect: FloatingRect;
9
+ }
10
+
11
+ export interface ShellCursorOcclusionRect extends LayoutBounds {
12
+ paneId: string;
13
+ zIndex: number;
14
+ }
15
+
16
+ interface ResolveShellCursorOcclusionRectsOptions {
17
+ contentHeight: number;
18
+ dragFloatingRect: { paneId: string; rect: FloatingRect } | null;
19
+ nativePaneChrome: boolean;
20
+ overlayOpen: boolean;
21
+ transientFocusActive: boolean;
22
+ visibleFloatingPanes: readonly VisibleFloatingPane[];
23
+ width: number;
24
+ }
25
+
26
+ export function resolveShellCursorOcclusionRects({
27
+ contentHeight,
28
+ dragFloatingRect,
29
+ nativePaneChrome,
30
+ overlayOpen,
31
+ transientFocusActive,
32
+ visibleFloatingPanes,
33
+ width,
34
+ }: ResolveShellCursorOcclusionRectsOptions): ShellCursorOcclusionRect[] {
35
+ if (nativePaneChrome || overlayOpen || transientFocusActive || visibleFloatingPanes.length === 0) {
36
+ return [];
37
+ }
38
+
39
+ return visibleFloatingPanes.map(({ pane, rect }) => {
40
+ const paneId = pane.instance.instanceId;
41
+ const visibleRect = dragFloatingRect?.paneId === paneId
42
+ ? constrainFloatingRectToBounds(dragFloatingRect.rect, width, contentHeight)
43
+ : rect;
44
+ return {
45
+ paneId,
46
+ zIndex: pane.floating?.zIndex ?? 50,
47
+ x: Math.floor(visibleRect.x),
48
+ y: Math.floor(visibleRect.y),
49
+ width: Math.max(0, Math.ceil(visibleRect.width)),
50
+ height: Math.max(0, Math.ceil(visibleRect.height)),
51
+ };
52
+ });
53
+ }
54
+
55
+ function readRenderableBounds(renderable: unknown): LayoutBounds | null {
56
+ if (!renderable || typeof renderable !== "object") return null;
57
+ const record = renderable as Record<string, unknown>;
58
+ const getRect = record.getBoundingClientRect;
59
+ if (typeof getRect === "function") {
60
+ const rect = getRect.call(renderable) as Partial<LayoutBounds> | null | undefined;
61
+ if (
62
+ rect
63
+ && typeof rect.x === "number"
64
+ && typeof rect.y === "number"
65
+ && typeof rect.width === "number"
66
+ && typeof rect.height === "number"
67
+ ) {
68
+ return rect as LayoutBounds;
69
+ }
70
+ }
71
+
72
+ const absoluteBounds = record.absoluteBounds as Partial<LayoutBounds> | null | undefined;
73
+ if (
74
+ absoluteBounds
75
+ && typeof absoluteBounds.x === "number"
76
+ && typeof absoluteBounds.y === "number"
77
+ && typeof absoluteBounds.width === "number"
78
+ && typeof absoluteBounds.height === "number"
79
+ ) {
80
+ return absoluteBounds as LayoutBounds;
81
+ }
82
+
83
+ const x = typeof record.screenX === "number"
84
+ ? record.screenX
85
+ : typeof record.absoluteX === "number"
86
+ ? record.absoluteX
87
+ : typeof record.x === "number"
88
+ ? record.x
89
+ : null;
90
+ const y = typeof record.screenY === "number"
91
+ ? record.screenY
92
+ : typeof record.absoluteY === "number"
93
+ ? record.absoluteY
94
+ : typeof record.y === "number"
95
+ ? record.y
96
+ : null;
97
+ const width = typeof record.width === "number" ? record.width : null;
98
+ const height = typeof record.height === "number" ? record.height : null;
99
+ if (x == null || y == null || width == null || height == null) return null;
100
+ return { x, y, width, height };
101
+ }
102
+
103
+ function readRenderablePaneId(renderable: unknown): string | null {
104
+ let current = renderable;
105
+ while (current && typeof current === "object") {
106
+ const record = current as Record<string, unknown>;
107
+ const paneId = record["data-gloom-pane-id"];
108
+ if (typeof paneId === "string") return paneId;
109
+ current = record.parent;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function rectContainsCursor(rect: LayoutBounds, cursor: NativeCursorState): boolean {
115
+ return cursor.x >= rect.x
116
+ && cursor.x < rect.x + rect.width
117
+ && cursor.y >= rect.y
118
+ && cursor.y < rect.y + rect.height;
119
+ }
120
+
121
+ function toTerminalRect(shellBounds: LayoutBounds, rect: ShellCursorOcclusionRect): ShellCursorOcclusionRect {
122
+ return {
123
+ ...rect,
124
+ x: Math.floor(shellBounds.x + rect.x + 1),
125
+ y: Math.floor(shellBounds.y + rect.y + 1),
126
+ };
127
+ }
128
+
129
+ function resolveCursorOwnerZIndex(
130
+ cursorOwnerPaneId: string | null,
131
+ rects: readonly ShellCursorOcclusionRect[],
132
+ ): number {
133
+ if (!cursorOwnerPaneId) return Number.NEGATIVE_INFINITY;
134
+ return rects.find((rect) => rect.paneId === cursorOwnerPaneId)?.zIndex ?? 0;
135
+ }
136
+
137
+ function shouldHideCursor({
138
+ cursor,
139
+ cursorOwnerPaneId,
140
+ terminalRects,
141
+ }: {
142
+ cursor: NativeCursorState;
143
+ cursorOwnerPaneId: string | null;
144
+ terminalRects: readonly ShellCursorOcclusionRect[];
145
+ }): boolean {
146
+ const ownerZIndex = resolveCursorOwnerZIndex(cursorOwnerPaneId, terminalRects);
147
+ return terminalRects.some((rect) => {
148
+ if (rect.paneId === cursorOwnerPaneId) return false;
149
+ if (rect.zIndex <= ownerZIndex) return false;
150
+ return rectContainsCursor(rect, cursor);
151
+ });
152
+ }
153
+
154
+ export function useShellCursorOcclusionGuard({
155
+ occlusionRects,
156
+ shellRef,
157
+ }: {
158
+ occlusionRects: readonly ShellCursorOcclusionRect[];
159
+ shellRef: RefObject<unknown>;
160
+ }) {
161
+ const nativeRenderer = useNativeRenderer();
162
+ const stateRef = useRef({ occlusionRects, shellRef });
163
+ stateRef.current = { occlusionRects, shellRef };
164
+
165
+ useEffect(() => {
166
+ if (
167
+ !nativeRenderer.addPostProcessFn
168
+ || !nativeRenderer.removePostProcessFn
169
+ || !nativeRenderer.getCursorState
170
+ || !nativeRenderer.setCursorPosition
171
+ ) {
172
+ return;
173
+ }
174
+
175
+ const guardCursor: NativePostProcessFn = () => {
176
+ const cursor = nativeRenderer.getCursorState?.();
177
+ if (!cursor?.visible) return;
178
+ const shellBounds = readRenderableBounds(stateRef.current.shellRef.current);
179
+ if (!shellBounds) return;
180
+
181
+ const terminalRects = stateRef.current.occlusionRects.map((rect) => toTerminalRect(shellBounds, rect));
182
+ if (terminalRects.length === 0) return;
183
+
184
+ const cursorOwnerPaneId = readRenderablePaneId(nativeRenderer.currentFocusedEditor);
185
+ if (!shouldHideCursor({ cursor, cursorOwnerPaneId, terminalRects })) return;
186
+ nativeRenderer.setCursorPosition?.(0, 0, false);
187
+ };
188
+
189
+ nativeRenderer.addPostProcessFn(guardCursor);
190
+ nativeRenderer.requestRender();
191
+ return () => {
192
+ nativeRenderer.removePostProcessFn?.(guardCursor);
193
+ nativeRenderer.requestRender();
194
+ };
195
+ }, [nativeRenderer]);
196
+ }
@@ -86,9 +86,11 @@ export interface ShellDragRuntimeState {
86
86
 
87
87
  export function useShellDragRuntimeState({
88
88
  contentHeight,
89
+ throttleFloatingPreview = false,
89
90
  width,
90
91
  }: {
91
92
  contentHeight: number;
93
+ throttleFloatingPreview?: boolean;
92
94
  width: number;
93
95
  }): ShellDragRuntimeState {
94
96
  const dragRef = useRef<DragMode | null>(null);
@@ -104,15 +106,20 @@ export function useShellDragRuntimeState({
104
106
  });
105
107
 
106
108
  const updateDragFloatingRect = useCallback((next: { paneId: string; rect: FloatingRect } | null) => {
107
- pendingDragFloatingRectRef.current = next
109
+ const constrainedNext = next
108
110
  ? { paneId: next.paneId, rect: constrainFloatingRectToBounds(next.rect, width, contentHeight) }
109
111
  : null;
112
+ pendingDragFloatingRectRef.current = constrainedNext;
110
113
  if (!next) {
111
114
  setDragFloatingRect(null);
112
115
  return;
113
116
  }
117
+ if (!throttleFloatingPreview) {
118
+ setDragFloatingRect(constrainedNext);
119
+ return;
120
+ }
114
121
  flushDragFloatingRect();
115
- }, [contentHeight, flushDragFloatingRect, width]);
122
+ }, [contentHeight, flushDragFloatingRect, throttleFloatingPreview, width]);
116
123
 
117
124
  const updateDividerPreview = useCallback((next: DividerPreviewState | null) => {
118
125
  dividerPreviewRef.current = next;
@@ -13,6 +13,7 @@ import {
13
13
  import { cloneLayout, createDefaultConfig, TICKER_RESEARCH_PANE_ID, type LayoutConfig } from "../../../types/config";
14
14
  import type { PluginRegistry } from "../../../plugins/registry";
15
15
  import type { PaneProps } from "../../../types/plugin";
16
+ import { Textarea } from "../../../ui";
16
17
  import { Header } from "../header";
17
18
  import {
18
19
  buildNativeWindowState,
@@ -222,7 +223,7 @@ describe("Header", () => {
222
223
  );
223
224
 
224
225
  await testSetup.renderOnce();
225
- const frame = testSetup.captureCharFrame();
226
+ const frame = testSetup!.captureCharFrame();
226
227
 
227
228
  expect(frame).toContain("v0.3.0 available");
228
229
  expect(frame).toContain("starting download");
@@ -242,7 +243,7 @@ describe("Header", () => {
242
243
  );
243
244
 
244
245
  await testSetup.renderOnce();
245
- const frame = testSetup.captureCharFrame();
246
+ const frame = testSetup!.captureCharFrame();
246
247
 
247
248
  expect(frame).toContain("v0.3.0 available");
248
249
  expect(frame).toContain("run npm install -g gloomberb@latest");
@@ -564,6 +565,95 @@ describe("Shell", () => {
564
565
  expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
565
566
  });
566
567
 
568
+ test("updates the floating pane preview before mouse release", async () => {
569
+ const config = createDefaultConfig("/tmp/gloomberb-shell-live-floating-drag-test");
570
+ const floatingLayout = cloneLayout(config.layout);
571
+ floatingLayout.dockRoot = { kind: "pane", instanceId: "portfolio-list:main" };
572
+ floatingLayout.floating = [{ instanceId: "ticker-detail:main", x: 8, y: 2, width: 32, height: 10, zIndex: 75 }];
573
+ await renderShellForWindowModeTest(
574
+ createShellStateWithLayout(config, floatingLayout, "ticker-detail:main"),
575
+ { width: 80, height: 18 },
576
+ );
577
+
578
+ await act(async () => {
579
+ await testSetup!.mockMouse.pressDown(10, 3);
580
+ await testSetup!.renderOnce();
581
+ await testSetup!.mockMouse.moveTo(16, 6);
582
+ await testSetup!.renderOnce();
583
+ await testSetup!.renderOnce();
584
+ });
585
+
586
+ const frame = testSetup!.captureCharFrame();
587
+ const rows = frame.split("\n");
588
+ expect(rows[2]?.indexOf("┌─:: Main Portfolio") ?? -1).toBeLessThan(0);
589
+ expect(rows[5]?.indexOf("┌─:: Main Portfolio")).toBeGreaterThanOrEqual(14);
590
+
591
+ await act(async () => {
592
+ await testSetup!.mockMouse.release(16, 6);
593
+ await testSetup!.renderOnce();
594
+ });
595
+ });
596
+
597
+ test("keeps the focused textarea cursor visible when it is not covered", async () => {
598
+ const config = createDefaultConfig("/tmp/gloomberb-shell-cursor-visible-test");
599
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
600
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
601
+ const registry = createShellPluginRegistry({
602
+ portfolioListComponent: ({ focused, width, height }) => (
603
+ <Textarea
604
+ initialValue=""
605
+ focused={focused}
606
+ width={width}
607
+ height={Math.max(1, height)}
608
+ />
609
+ ),
610
+ tickerDetailComponent: () => <text>Chart Body</text>,
611
+ });
612
+ const layout = {
613
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
614
+ instances: [{ ...mainPane }, { ...detailPane }],
615
+ floating: [{ instanceId: "ticker-detail:main", x: 30, y: 2, width: 24, height: 8, zIndex: 75 }],
616
+ detached: [],
617
+ };
618
+ await renderShellForWindowModeTest(
619
+ createShellStateWithLayout(config, layout, "portfolio-list:main"),
620
+ { registry, width: 80, height: 18 },
621
+ );
622
+ await testSetup!.renderOnce();
623
+
624
+ expect(testSetup!.renderer.getCursorState().visible).toBe(true);
625
+ });
626
+
627
+ test("hides the textarea cursor when a higher floating pane covers it", async () => {
628
+ const config = createDefaultConfig("/tmp/gloomberb-shell-cursor-occlusion-test");
629
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
630
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
631
+ const registry = createShellPluginRegistry({
632
+ portfolioListComponent: ({ focused, width, height }) => (
633
+ <Textarea
634
+ initialValue=""
635
+ focused={focused}
636
+ width={width}
637
+ height={Math.max(1, height)}
638
+ />
639
+ ),
640
+ tickerDetailComponent: () => <text>Chart Body</text>,
641
+ });
642
+ const layout = {
643
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
644
+ instances: [{ ...mainPane }, { ...detailPane }],
645
+ floating: [{ instanceId: "ticker-detail:main", x: 0, y: 0, width: 40, height: 8, zIndex: 75 }],
646
+ detached: [],
647
+ };
648
+ await renderShellForWindowModeTest(
649
+ createShellStateWithLayout(config, layout, "portfolio-list:main"),
650
+ { registry, width: 80, height: 18 },
651
+ );
652
+ await testSetup!.renderOnce();
653
+
654
+ expect(testSetup!.renderer.getCursorState().visible).toBe(false);
655
+ });
656
+
567
657
  test("keeps focused pane local detail state when maximizing a floating pane", async () => {
568
658
  const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-local-state-test");
569
659
  const detailPane = requireLayoutInstance(config, "ticker-detail:main");
@@ -1,4 +1,4 @@
1
- import { AsciiText, Box, Text, compactContextMenuItems, useContextMenu, useUiHost } from "../../../ui";
1
+ import { AsciiText, Box, Text, compactContextMenuItems, useContextMenu, useUiHost, type BoxRenderable } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useRendererHost, useUiCapabilities } from "../../../ui";
4
4
  import { useViewport } from "../../../react/input";
@@ -61,6 +61,10 @@ import {
61
61
  import { useShellPaneActions } from "./pane/actions";
62
62
  import { resolvePaneFocusSourceLayout } from "./fullscreen";
63
63
  import { useTransientLayout } from "../transient-layout";
64
+ import {
65
+ resolveShellCursorOcclusionRects,
66
+ useShellCursorOcclusionGuard,
67
+ } from "./cursor-occlusion";
64
68
 
65
69
  export { resolveAppHeaderHeightCells } from "./chrome";
66
70
  export { buildNativeWindowState } from "./native/window-state";
@@ -102,6 +106,7 @@ export function Shell({
102
106
  const { nativePaneChrome = false, nativeContextMenu, precisePointer, titleBarOverlay, cellHeightPx } = useUiCapabilities();
103
107
  const { showContextMenu } = useContextMenu();
104
108
  const { width, height } = useViewport();
109
+ const shellRef = useRef<BoxRenderable | null>(null);
105
110
 
106
111
  const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
107
112
  const contentHeight = Math.max(1, height - appHeaderHeight - (statusBarVisible ? 1 : 0));
@@ -124,7 +129,11 @@ export function Shell({
124
129
  }, []);
125
130
  const overlayOpen = commandBarOpen || dialogOpen || !!menuState;
126
131
 
127
- const dragRuntime = useShellDragRuntimeState({ contentHeight, width });
132
+ const dragRuntime = useShellDragRuntimeState({
133
+ contentHeight,
134
+ throttleFloatingPreview: nativePaneChrome,
135
+ width,
136
+ });
128
137
  const {
129
138
  cancelActiveDrag,
130
139
  dividerPreview,
@@ -209,6 +218,27 @@ export function Shell({
209
218
  pluginRegistry,
210
219
  width,
211
220
  });
221
+ const cursorOcclusionRects = useMemo(() => resolveShellCursorOcclusionRects({
222
+ contentHeight,
223
+ dragFloatingRect,
224
+ nativePaneChrome,
225
+ overlayOpen,
226
+ transientFocusActive,
227
+ visibleFloatingPanes,
228
+ width,
229
+ }), [
230
+ contentHeight,
231
+ dragFloatingRect,
232
+ nativePaneChrome,
233
+ overlayOpen,
234
+ transientFocusActive,
235
+ visibleFloatingPanes,
236
+ width,
237
+ ]);
238
+ useShellCursorOcclusionGuard({
239
+ occlusionRects: cursorOcclusionRects,
240
+ shellRef,
241
+ });
212
242
 
213
243
  const {
214
244
  closeAllFloatingPanes,
@@ -485,8 +515,13 @@ export function Shell({
485
515
 
486
516
  return (
487
517
  <Box
518
+ ref={shellRef}
488
519
  flexDirection="row"
489
520
  flexGrow={1}
521
+ flexShrink={1}
522
+ flexBasis={0}
523
+ minWidth={0}
524
+ minHeight={0}
490
525
  height={nativePaneChrome ? undefined : contentHeight}
491
526
  position={nativePaneChrome ? "relative" : undefined}
492
527
  overflow="hidden"
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getDockedPaneIds } from "../../../plugins/pane-manager";
3
+ import { createPaneInstance, type LayoutConfig } from "../../../types/config";
4
+ import { resolveShellVisibleLayout } from "./visible-layout";
5
+
6
+ describe("shell visible layout", () => {
7
+ test("removes unregistered pane types before resolving dock geometry", () => {
8
+ const layout: LayoutConfig = {
9
+ dockRoot: {
10
+ kind: "split",
11
+ axis: "horizontal",
12
+ ratio: 0.5,
13
+ first: { kind: "pane", instanceId: "missing:main" },
14
+ second: { kind: "pane", instanceId: "chat:main" },
15
+ },
16
+ instances: [
17
+ createPaneInstance("missing-plugin", { instanceId: "missing:main" }),
18
+ createPaneInstance("chat", { instanceId: "chat:main" }),
19
+ ],
20
+ floating: [],
21
+ detached: [],
22
+ };
23
+
24
+ const visibleLayout = resolveShellVisibleLayout(
25
+ layout,
26
+ new Set(),
27
+ new Map([["chat", true]]),
28
+ );
29
+
30
+ expect(visibleLayout.instances.map((instance) => instance.instanceId)).toEqual(["chat:main"]);
31
+ expect(getDockedPaneIds(visibleLayout)).toEqual(["chat:main"]);
32
+ });
33
+ });
@@ -6,8 +6,9 @@ import {
6
6
  type ResolvedPane,
7
7
  } from "../../../plugins/pane-manager";
8
8
  import type { PluginRegistry } from "../../../plugins/registry";
9
- import { removePaneInstances, type LayoutConfig } from "../../../types/config";
9
+ import type { LayoutConfig } from "../../../types/config";
10
10
  import { constrainFloatingRectToBounds } from "./drag";
11
+ import { resolveShellVisibleLayout } from "./visible-layout";
11
12
 
12
13
  interface ShellVisibleLayoutOptions {
13
14
  disabledPlugins: readonly string[];
@@ -34,15 +35,9 @@ export function useShellVisibleLayout({
34
35
  return paneIds;
35
36
  }, [disabledPlugins, pluginRegistry]);
36
37
 
37
- const hiddenInstanceIds = useMemo(() => (
38
- layout.instances
39
- .filter((instance) => disabledPaneIds.has(instance.paneId))
40
- .map((instance) => instance.instanceId)
41
- ), [disabledPaneIds, layout.instances]);
42
-
43
38
  const visibleLayout = useMemo(
44
- () => (hiddenInstanceIds.length > 0 ? removePaneInstances(layout, hiddenInstanceIds) : layout),
45
- [hiddenInstanceIds, layout],
39
+ () => resolveShellVisibleLayout(layout, disabledPaneIds, pluginRegistry.panes),
40
+ [disabledPaneIds, layout, pluginRegistry.panes],
46
41
  );
47
42
 
48
43
  return { disabledPaneIds, visibleLayout };
@@ -111,11 +111,14 @@ export function ShellPaneLayers({
111
111
  >
112
112
  <PaneFooterProvider>
113
113
  {(footer) => {
114
- const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
114
+ const showFooter = hasPaneFooterContent(footer);
115
+ const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
116
+ const renderFooter = reserveFooter || showFooter;
115
117
  const bodyFrame = resolvePaneBodyFrame({
116
118
  width: rect.width,
117
119
  height: rect.height,
118
120
  nativePaneChrome,
121
+ footerVisible: renderFooter,
119
122
  reserveFooter,
120
123
  });
121
124
  return (
@@ -165,11 +168,14 @@ export function ShellPaneLayers({
165
168
  return (
166
169
  <PaneFooterProvider key={`float:${pane.instance.instanceId}`}>
167
170
  {(footer) => {
168
- const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
171
+ const showFooter = hasPaneFooterContent(footer);
172
+ const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
173
+ const renderFooter = reserveFooter || showFooter;
169
174
  const bodyFrame = resolvePaneBodyFrame({
170
175
  width: preview.width,
171
176
  height: preview.height,
172
177
  nativePaneChrome,
178
+ footerVisible: renderFooter,
173
179
  reserveFooter,
174
180
  });
175
181
  return (
@@ -0,0 +1,17 @@
1
+ import {
2
+ removeUnavailablePaneTypes,
3
+ type PaneTypeAvailability,
4
+ } from "../../../plugins/pane-manager";
5
+ import type { LayoutConfig } from "../../../types/config";
6
+
7
+ export function resolveShellVisibleLayout(
8
+ layout: LayoutConfig,
9
+ disabledPaneIds: ReadonlySet<string>,
10
+ registeredPaneIds: PaneTypeAvailability,
11
+ ): LayoutConfig {
12
+ return removeUnavailablePaneTypes(
13
+ layout,
14
+ registeredPaneIds,
15
+ { disabledPaneIds },
16
+ );
17
+ }
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { testRender } from "../../renderers/opentui/test-utils";
3
3
  import { AppContext, createInitialState } from "../../state/app/context";
4
- import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../types/config";
4
+ import { cloneLayout, createDefaultConfig, TICKER_RESEARCH_PANE_ID, type LayoutConfig } from "../../types/config";
5
5
  import type { AppNotificationRequest } from "../../types/plugin";
6
6
  import { StatusBar } from "./status-bar";
7
7
  import { setSharedRegistryForTests } from "../../plugins/registry";
@@ -174,6 +174,10 @@ describe("StatusBar", () => {
174
174
  const notifications: AppNotificationRequest[] = [];
175
175
 
176
176
  setSharedRegistryForTests({
177
+ panes: new Map([
178
+ ["portfolio-list", {}],
179
+ [TICKER_RESEARCH_PANE_ID, {}],
180
+ ]),
177
181
  getLayoutFn: () => state.config.layout,
178
182
  getTermSizeFn: () => ({ width: 120, height: 40 }),
179
183
  updateLayoutFn: (layout: LayoutConfig) => { updatedLayout = layout; },
@@ -233,6 +237,7 @@ describe("StatusBar", () => {
233
237
  globalThis.clearTimeout = (() => {}) as typeof clearTimeout;
234
238
 
235
239
  setSharedRegistryForTests({
240
+ panes: new Map(),
236
241
  getLayoutFn: () => state.config.layout,
237
242
  getTermSizeFn: () => ({ width: 120, height: 40 }),
238
243
  updateLayoutFn: () => {},
@@ -114,7 +114,11 @@ export function StatusBar() {
114
114
  event?.stopPropagation?.();
115
115
  if (!registry) return;
116
116
  const { width, height } = registry.getTermSizeFn();
117
- registry.updateLayoutFn(gridlockAllPanes(registry.getLayoutFn(), { x: 0, y: 0, width, height }));
117
+ registry.updateLayoutFn(gridlockAllPanes(
118
+ registry.getLayoutFn(),
119
+ { x: 0, y: 0, width, height },
120
+ registry.panes,
121
+ ));
118
122
  notifyGridlockComplete(registry.notify.bind(registry), () => {
119
123
  dispatch({ type: "UNDO_LAYOUT" });
120
124
  });