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
@@ -1,16 +1,26 @@
1
- import { useMemo } from "react";
2
- import { Box, ChartSurface, Text } from "../../../../ui";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { Box, ChartSurface, Text, type BoxRenderable, type ChartSurfaceProps, useUiCapabilities } from "../../../../ui";
3
+ import { colors as themeColors } from "../../../../theme/colors";
3
4
  import { useThemeColors } from "../../../../theme/theme-context";
5
+ import { PriceAxisLabels } from "../../price-axis-labels";
4
6
  import type { ProjectedChartPoint } from "../../core/data";
5
7
  import {
6
8
  buildChartScene,
7
9
  computeGridLines,
8
- formatAxisCell,
9
10
  renderChart,
10
11
  type RenderChartOptions,
11
12
  } from "../../core/renderer";
12
13
  import { renderNativeChartBase, type NativeChartBitmap } from "../../native/chart-rasterizer";
13
14
  import { useStaticChartBitmapSize } from "./bitmap";
15
+ import {
16
+ StaticXAxisLabels,
17
+ StaticXMarkerLabels,
18
+ StaticXMarkerOverlay,
19
+ clampRatio,
20
+ type StaticChartXMarker,
21
+ } from "./axis-overlays";
22
+
23
+ export type { StaticChartXMarker } from "./axis-overlays";
14
24
 
15
25
  export interface StaticChartSurfaceProps extends Omit<
16
26
  RenderChartOptions,
@@ -23,11 +33,52 @@ export interface StaticChartSurfaceProps extends Omit<
23
33
  volumeHeight?: number;
24
34
  showTimeAxis?: boolean;
25
35
  timeAxisColor?: string;
36
+ xAxisLabels?: string[];
37
+ xAxisColor?: string;
38
+ formatXAxisCursorValue?: (xRatio: number) => string;
39
+ xMarkers?: StaticChartXMarker[];
26
40
  yAxisLabel?: string;
27
41
  yAxisColor?: string;
28
42
  formatYAxisValue?: (value: number) => string;
29
43
  }
30
44
 
45
+ interface ChartMouseEventLike {
46
+ x: number;
47
+ y: number;
48
+ preciseX?: number;
49
+ preciseY?: number;
50
+ preventDefault?: () => void;
51
+ }
52
+
53
+ function localPlotPointer(
54
+ event: ChartMouseEventLike,
55
+ renderable: BoxRenderable | null,
56
+ ): { x: number; y: number } | null {
57
+ if (!renderable) return null;
58
+ const originX = typeof renderable.absoluteX === "number"
59
+ ? renderable.absoluteX
60
+ : typeof renderable.x === "number"
61
+ ? renderable.x
62
+ : 0;
63
+ const originY = typeof renderable.absoluteY === "number"
64
+ ? renderable.absoluteY
65
+ : typeof renderable.y === "number"
66
+ ? renderable.y
67
+ : 0;
68
+ const rawX = typeof event.preciseX === "number" ? event.preciseX : event.x;
69
+ const rawY = typeof event.preciseY === "number" ? event.preciseY : event.y;
70
+ const x = rawX - originX;
71
+ const y = rawY - originY;
72
+ const width = typeof renderable.width === "number" ? renderable.width : 0;
73
+ const height = typeof renderable.height === "number" ? renderable.height : 0;
74
+ if (!Number.isFinite(x) || !Number.isFinite(y) || width <= 0 || height <= 0) return null;
75
+ if (x < 0 || y < 0 || x >= width || y >= height) return null;
76
+ return {
77
+ x: Math.max(0, Math.min(x, Math.max(width - 1, 0))),
78
+ y: Math.max(0, Math.min(y, Math.max(height - 1, 0))),
79
+ };
80
+ }
81
+
31
82
  export function StaticChartSurface({
32
83
  points,
33
84
  width,
@@ -43,16 +94,24 @@ export function StaticChartSurface({
43
94
  volumeHeight = 0,
44
95
  showTimeAxis = false,
45
96
  timeAxisColor,
97
+ xAxisLabels,
98
+ xAxisColor,
99
+ formatXAxisCursorValue,
100
+ xMarkers,
46
101
  yAxisLabel,
47
102
  yAxisColor,
48
103
  formatYAxisValue,
49
104
  }: StaticChartSurfaceProps) {
50
105
  useThemeColors();
51
- const timeAxisRows = showTimeAxis ? 1 : 0;
106
+ const { cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
107
+ const plotRef = useRef<BoxRenderable | null>(null);
108
+ const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
109
+ const xAxisRows = showTimeAxis || (xAxisLabels?.length ?? 0) > 0 ? 1 : 0;
110
+ const xMarkerRows = xMarkers?.some((marker) => marker.label) ? 1 : 0;
52
111
  const labelRows = yAxisLabel ? 1 : 0;
53
112
  const totalWidth = Math.max(1, Math.floor(width));
54
113
  const totalHeight = Math.max(1, Math.floor(height));
55
- const plotHeight = Math.max(1, totalHeight - timeAxisRows - labelRows);
114
+ const plotHeight = Math.max(1, totalHeight - xAxisRows - xMarkerRows - labelRows);
56
115
  const axisSourceOptions = useMemo<RenderChartOptions>(() => ({
57
116
  width: totalWidth,
58
117
  height: plotHeight,
@@ -104,8 +163,8 @@ export function StaticChartSurface({
104
163
  height: plotHeight,
105
164
  showVolume,
106
165
  volumeHeight,
107
- cursorX: null,
108
- cursorY: null,
166
+ cursorX: cursor?.x ?? null,
167
+ cursorY: cursor?.y ?? null,
109
168
  mode,
110
169
  axisMode,
111
170
  currency,
@@ -122,6 +181,7 @@ export function StaticChartSurface({
122
181
  mode,
123
182
  plotHeight,
124
183
  plotWidth,
184
+ cursor,
125
185
  showVolume,
126
186
  timeAxisDates,
127
187
  volumeHeight,
@@ -133,6 +193,14 @@ export function StaticChartSurface({
133
193
  const effectiveAxisLabelsByRow = useMemo(() => {
134
194
  return new Map(axisLabels.map((entry) => [entry.row, entry.label] as const));
135
195
  }, [axisLabels]);
196
+ const cursorAxisLabel = useMemo(() => {
197
+ if (!formatYAxisValue || textResult.crosshairPrice === null) return null;
198
+ return formatYAxisValue(textResult.crosshairPrice);
199
+ }, [formatYAxisValue, textResult.crosshairPrice]);
200
+ const cursorXAxisLabel = useMemo(() => {
201
+ if (!formatXAxisCursorValue || !cursor) return null;
202
+ return formatXAxisCursorValue(clampRatio(cursor.x / Math.max(plotWidth - 1, 1)));
203
+ }, [cursor, formatXAxisCursorValue, plotWidth]);
136
204
  const effectiveAxisWidth = axisWidth;
137
205
  const effectiveAxisGap = effectiveAxisWidth > 0 ? 1 : 0;
138
206
  const bitmap = useMemo<NativeChartBitmap | null>(() => {
@@ -141,40 +209,115 @@ export function StaticChartSurface({
141
209
  if (!scene) return null;
142
210
  return renderNativeChartBase(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
143
211
  }, [bitmapSize, points, renderOptions]);
212
+ const canvasCrosshair = useMemo<ChartSurfaceProps["crosshair"]>(() => {
213
+ if (!bitmap || !cursor) return null;
214
+ return {
215
+ pixelX: Math.round(clampRatio(cursor.x / Math.max(plotWidth - 1, 1)) * Math.max(bitmap.width - 1, 0)),
216
+ pixelY: Math.round(clampRatio(cursor.y / Math.max(plotHeight - 1, 1)) * Math.max(bitmap.height - 1, 0)),
217
+ color: colors.crosshairColor,
218
+ };
219
+ }, [bitmap, colors.crosshairColor, cursor, plotHeight, plotWidth]);
220
+ const handleCursorEvent = useCallback((event: ChartMouseEventLike) => {
221
+ const pointer = localPlotPointer(event, plotRef.current);
222
+ if (!pointer) return;
223
+ event.preventDefault?.();
224
+ setCursor((current) => (
225
+ current && current.x === pointer.x && current.y === pointer.y ? current : pointer
226
+ ));
227
+ }, []);
228
+ const clearCursor = useCallback(() => {
229
+ setCursor(null);
230
+ }, []);
144
231
 
145
232
  return (
146
- <Box flexDirection="column" width={totalWidth} height={plotHeight + timeAxisRows + labelRows}>
233
+ <Box flexDirection="column" width={totalWidth} height={plotHeight + xAxisRows + xMarkerRows + labelRows}>
147
234
  {yAxisLabel ? (
148
235
  <Box height={1}>
149
236
  <Text fg={yAxisColor}>{yAxisLabel}</Text>
150
237
  </Box>
151
238
  ) : null}
152
239
  <Box flexDirection="row" height={plotHeight}>
153
- <ChartSurface
240
+ <Box
241
+ ref={plotRef}
242
+ position="relative"
154
243
  width={plotWidth}
155
244
  height={plotHeight}
156
- flexDirection="column"
157
- bitmaps={bitmap ? [bitmap] : null}
245
+ onMouseMove={handleCursorEvent}
246
+ onMouseDown={handleCursorEvent}
247
+ onMouseOut={clearCursor}
248
+ data-gloom-role="static-chart-plot"
158
249
  >
159
- {textResult.lines.map((line, index) => (
160
- <Text key={index} content={line} />
161
- ))}
162
- </ChartSurface>
250
+ <ChartSurface
251
+ width={plotWidth}
252
+ height={plotHeight}
253
+ flexDirection="column"
254
+ bitmaps={bitmap ? [bitmap] : null}
255
+ crosshair={canvasCrosshair}
256
+ >
257
+ {textResult.lines.map((line, index) => (
258
+ <Text key={index} content={line} />
259
+ ))}
260
+ </ChartSurface>
261
+ {xMarkers ? (
262
+ <StaticXMarkerOverlay
263
+ markers={xMarkers}
264
+ width={plotWidth}
265
+ height={plotHeight}
266
+ fallbackColor={xAxisColor ?? timeAxisColor}
267
+ />
268
+ ) : null}
269
+ </Box>
163
270
  {effectiveAxisWidth > 0 ? (
164
271
  <>
165
272
  <Box width={effectiveAxisGap} />
166
- <Box width={effectiveAxisWidth} height={plotHeight} flexDirection="column">
167
- {Array.from({ length: plotHeight }, (_, row) => (
168
- <Text key={row} fg={yAxisColor}>
169
- {formatAxisCell(effectiveAxisLabelsByRow.get(row) ?? null, effectiveAxisWidth)}
170
- </Text>
171
- ))}
172
- </Box>
273
+ <PriceAxisLabels
274
+ axisLabels={effectiveAxisLabelsByRow}
275
+ axisWidth={effectiveAxisWidth}
276
+ axisSectionWidth={effectiveAxisWidth}
277
+ height={plotHeight}
278
+ cursorRow={textResult.cursorRow}
279
+ cursorPixelY={cursor ? cursor.y * cellHeightPx : null}
280
+ cursorLabel={cursorAxisLabel}
281
+ cursorColor={colors.crosshairColor}
282
+ axisColor={yAxisColor}
283
+ />
173
284
  </>
174
285
  ) : null}
175
286
  </Box>
176
- {showTimeAxis ? (
177
- <Text fg={timeAxisColor}>{textResult.timeLabels}</Text>
287
+ {showTimeAxis || (xAxisLabels?.length ?? 0) > 0 ? (
288
+ <Box height={1} flexDirection="row">
289
+ {xAxisLabels ? (
290
+ <StaticXAxisLabels
291
+ labels={xAxisLabels}
292
+ width={plotWidth}
293
+ color={xAxisColor ?? timeAxisColor}
294
+ cursorColumn={textResult.cursorColumn}
295
+ cursorPixelX={cursor ? cursor.x * cellWidthPx : null}
296
+ cursorLabel={cursorXAxisLabel}
297
+ cursorColor={colors.crosshairColor}
298
+ cursorBackgroundColor={colors.bgColor ?? themeColors.bg}
299
+ />
300
+ ) : (
301
+ <Text fg={timeAxisColor}>{textResult.timeLabels}</Text>
302
+ )}
303
+ {effectiveAxisWidth > 0 ? (
304
+ <>
305
+ <Box width={effectiveAxisGap} />
306
+ <Box width={effectiveAxisWidth} />
307
+ </>
308
+ ) : null}
309
+ </Box>
310
+ ) : null}
311
+ {xMarkers && xMarkerRows > 0 ? (
312
+ <Box height={1} flexDirection="row">
313
+ <StaticXMarkerLabels markers={xMarkers} width={plotWidth} fallbackColor={xAxisColor ?? timeAxisColor} />
314
+ {effectiveAxisWidth > 0 ? (
315
+ <>
316
+ <Box width={effectiveAxisGap} />
317
+ <Box width={effectiveAxisWidth} />
318
+ </>
319
+ ) : null}
320
+ </Box>
178
321
  ) : null}
179
322
  </Box>
180
323
  );
@@ -76,6 +76,31 @@ describe("stock chart pointer helpers", () => {
76
76
  });
77
77
  });
78
78
 
79
+ test("uses absolute renderable coordinates for desktop mouse hit testing", () => {
80
+ const pointer = getLocalPlotPointer({
81
+ x: 49,
82
+ y: 10,
83
+ preciseX: 49.5,
84
+ preciseY: 10.25,
85
+ modifiers: { shift: false, alt: false, ctrl: false },
86
+ }, {
87
+ x: 0,
88
+ y: 0,
89
+ absoluteX: 10,
90
+ absoluteY: 5,
91
+ width: 40,
92
+ height: 12,
93
+ }, renderer);
94
+
95
+ expect(pointer).toMatchObject({
96
+ hasPixelPrecision: false,
97
+ pixelX: null,
98
+ pixelY: null,
99
+ });
100
+ expect(pointer!.cellX).toBeCloseTo(39.5, 2);
101
+ expect(pointer!.cellY).toBeCloseTo(5.25, 2);
102
+ });
103
+
79
104
  test("can derive local pixels from cell coordinates for non-pixel fallbacks", () => {
80
105
  const pixels = projectCellCursorToLocalPixels(14, 5, renderable, renderer);
81
106
 
@@ -87,7 +87,11 @@ export function buildCurrentLayoutItems({
87
87
  kind: "action",
88
88
  action: () => {
89
89
  const { width, height } = pluginRegistry.getTermSizeFn();
90
- persistLayoutChange(gridlockAllPanes(currentLayout, { x: 0, y: 0, width, height }));
90
+ persistLayoutChange(gridlockAllPanes(
91
+ currentLayout,
92
+ { x: 0, y: 0, width, height },
93
+ pluginRegistry.panes,
94
+ ));
91
95
  notifyGridlockRevert();
92
96
  closeAll({ revertThemePreview: false });
93
97
  },
@@ -10,7 +10,7 @@ import { useThemeColors } from "../../theme/theme-context";
10
10
  import { hasPaneFooterContent, PaneFooterBar, PaneFooterProvider } from "./pane/footer";
11
11
  import { PaneBodyFrame, getPaneWindowAttributes } from "./pane/frame";
12
12
  import { PaneContent } from "./pane/content";
13
- import { resolvePaneBodyFrame } from "./pane/sizing";
13
+ import { resolvePaneBodyFrame, shouldReservePaneFooter } from "./pane/sizing";
14
14
  import { getPaneDisplayTitle } from "./pane/title";
15
15
  import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
16
16
  import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls";
@@ -130,6 +130,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
130
130
  <PaneFooterProvider>
131
131
  {(footer) => {
132
132
  const showFooter = hasPaneFooterContent(footer);
133
+ const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
134
+ const renderFooter = reserveFooter || showFooter;
133
135
  const headerHeightRows = titleBarOverlay ? TITLEBAR_OVERLAY_HEIGHT_PX / cellHeightPx : 1;
134
136
  const background = floatingPaneBg(focused);
135
137
  const titleBackground = floatingPaneTitleBg(focused);
@@ -137,7 +139,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
137
139
  width,
138
140
  height,
139
141
  nativePaneChrome,
140
- reserveFooter: showFooter,
142
+ footerVisible: renderFooter,
143
+ reserveFooter,
141
144
  headerRows: headerHeightRows,
142
145
  });
143
146
  const bodyWidth = bodyFrame.width ?? 1;
@@ -208,7 +211,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
208
211
  height={bodyHeight}
209
212
  />
210
213
  </PaneBodyFrame>
211
- {showFooter && <PaneFooterBar footer={footer} focused={focused} width={width} />}
214
+ {renderFooter && <PaneFooterBar footer={footer} focused={focused} width={width} />}
212
215
  </Box>
213
216
  );
214
217
  }}
@@ -80,7 +80,8 @@ export function FloatingPaneWrapper({
80
80
  const bg = floatingPaneBg(focused);
81
81
  const showFooter = hasPaneFooterContent(footer);
82
82
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
83
- const bodyFrame = resolvePaneBodyFrame({ height, nativePaneChrome, reserveFooter });
83
+ const renderFooter = reserveFooter || showFooter;
84
+ const bodyFrame = resolvePaneBodyFrame({ height, nativePaneChrome, footerVisible: renderFooter, reserveFooter });
84
85
 
85
86
  return (
86
87
  <Box
@@ -124,7 +125,7 @@ export function FloatingPaneWrapper({
124
125
  {children}
125
126
  </PaneBodyFrame>
126
127
 
127
- {reserveFooter && (
128
+ {renderFooter && (
128
129
  <PaneFooterBar
129
130
  footer={footer}
130
131
  focused={focused}
@@ -3,6 +3,7 @@ import { PaneInstanceProvider } from "../../../state/app/context";
3
3
  import { PaneKeyboardScrollController } from "../../../state/pane-scroll-registry";
4
4
  import { useThemeColors } from "../../../theme/theme-context";
5
5
  import type { PaneDef } from "../../../types/plugin";
6
+ import { Box } from "../../../ui";
6
7
 
7
8
  interface PaneContentProps {
8
9
  component: PaneDef["component"];
@@ -31,14 +32,25 @@ export const PaneContent = memo(function PaneContent({
31
32
  return (
32
33
  <PaneInstanceProvider paneId={paneId}>
33
34
  <PaneKeyboardScrollController paneId={paneId} focused={focused} />
34
- <Component
35
- paneId={paneId}
36
- paneType={paneType}
37
- focused={focused}
38
- width={width}
39
- height={height}
40
- close={onClose ? close : undefined}
41
- />
35
+ <Box
36
+ flexDirection="column"
37
+ flexGrow={1}
38
+ flexShrink={1}
39
+ flexBasis={0}
40
+ minWidth={0}
41
+ minHeight={0}
42
+ overflow="hidden"
43
+ data-gloom-role="pane-content"
44
+ >
45
+ <Component
46
+ paneId={paneId}
47
+ paneType={paneType}
48
+ focused={focused}
49
+ width={width}
50
+ height={height}
51
+ close={onClose ? close : undefined}
52
+ />
53
+ </Box>
42
54
  </PaneInstanceProvider>
43
55
  );
44
56
  });
@@ -3,6 +3,7 @@ import {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useMemo,
7
8
  useRef,
8
9
  useState,
@@ -17,6 +18,9 @@ import {
17
18
  type PaneHint,
18
19
  } from "./model";
19
20
 
21
+ const usePaneFooterRegistrationEffect =
22
+ typeof document === "undefined" ? useEffect : useLayoutEffect;
23
+
20
24
  interface PaneFooterContextValue {
21
25
  register(registrationId: string, registration: PaneFooterRegistration | null): void;
22
26
  unregister(registrationId: string): void;
@@ -85,14 +89,14 @@ export function usePaneFooter(
85
89
  const context = useContext(PaneFooterContext);
86
90
  const previousRegistrationRef = useRef<PaneFooterRegistration | null>(null);
87
91
 
88
- useEffect(() => {
92
+ usePaneFooterRegistrationEffect(() => {
89
93
  return () => {
90
94
  previousRegistrationRef.current = null;
91
95
  context?.unregister(registrationId);
92
96
  };
93
97
  }, [context, registrationId]);
94
98
 
95
- useEffect(() => {
99
+ usePaneFooterRegistrationEffect(() => {
96
100
  if (!context) return;
97
101
  const nextRegistration = factory() ?? null;
98
102
  if (samePaneFooterRegistration(previousRegistrationRef.current, nextRegistration)) return;
@@ -19,13 +19,13 @@ export function getPaneWindowAttributes({
19
19
  windowModeSelected?: boolean;
20
20
  showBorderColor?: boolean;
21
21
  }): Record<string, unknown> {
22
- if (!enabled) return {};
23
-
24
22
  const attributes: Record<string, unknown> = {
25
23
  "data-gloom-role": role,
26
- "data-focused": focused ? "true" : "false",
27
24
  };
28
25
  if (paneId) attributes["data-gloom-pane-id"] = paneId;
26
+ if (!enabled) return attributes;
27
+
28
+ attributes["data-focused"] = focused ? "true" : "false";
29
29
  if (floating != null) attributes["data-floating"] = floating ? "true" : "false";
30
30
  if (windowModeSelected != null) {
31
31
  attributes["data-window-mode-selected"] = windowModeSelected ? "true" : "false";
@@ -48,7 +48,7 @@ export function PaneBodyFrame({
48
48
  children: ReactNode;
49
49
  }) {
50
50
  return (
51
- <Box {...layoutProps} overflow="hidden" backgroundColor={backgroundColor}>
51
+ <Box {...layoutProps} overflow="hidden" backgroundColor={backgroundColor} data-gloom-role="pane-body">
52
52
  {children}
53
53
  </Box>
54
54
  );
@@ -1,5 +1,5 @@
1
- import { Box, Span, Text, useUiCapabilities } from "../../../ui";
2
- import type { ReactNode } from "react";
1
+ import { Box, Span, Text, useNativeRenderer, useUiCapabilities } from "../../../ui";
2
+ import { useCallback, useRef, type ReactNode } from "react";
3
3
  import { blendHex, colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../../theme/colors";
4
4
 
5
5
  const PANE_HEADER_HEIGHT = 1;
@@ -29,6 +29,18 @@ function truncateTitle(title: string, maxWidth: number): string {
29
29
  return `${title.slice(0, maxWidth - 2)}..`;
30
30
  }
31
31
 
32
+ function captureTerminalPointerDrag(renderer: unknown, renderable: unknown): void {
33
+ if (!renderable) return;
34
+ const hostCapture = (renderer as { captureMouseRenderable?: (target: unknown) => void }).captureMouseRenderable;
35
+ if (typeof hostCapture === "function") {
36
+ hostCapture.call(renderer, renderable);
37
+ return;
38
+ }
39
+ const capture = (renderer as { setCapturedRenderable?: (target: unknown) => void }).setCapturedRenderable;
40
+ if (typeof capture !== "function") return;
41
+ capture.call(renderer, renderable);
42
+ }
43
+
32
44
  function DesktopPaneButton({
33
45
  icon,
34
46
  onMouseDown,
@@ -107,11 +119,17 @@ export function PaneHeader({
107
119
  onCloseMouseDown,
108
120
  }: PaneHeaderProps) {
109
121
  const { nativePaneChrome } = useUiCapabilities();
122
+ const nativeRenderer = useNativeRenderer();
123
+ const terminalHeaderRef = useRef<unknown>(null);
110
124
  const visuallyFocused = focused || windowModeSelected;
111
125
  const backgroundColor = floating ? floatingPaneTitleBg(visuallyFocused) : paneTitleBg(visuallyFocused);
112
126
  const actionText = showActions ? PANE_HEADER_ACTION : " ";
113
127
  const closeText = floating ? PANE_HEADER_CLOSE : "";
114
128
  const textColor = paneTitleText(visuallyFocused, floating);
129
+ const handleTerminalHeaderMouseDown = useCallback((event: any) => {
130
+ captureTerminalPointerDrag(nativeRenderer, terminalHeaderRef.current);
131
+ onHeaderMouseDown?.(event);
132
+ }, [nativeRenderer, onHeaderMouseDown]);
115
133
 
116
134
  if (nativePaneChrome) {
117
135
  return (
@@ -201,7 +219,16 @@ export function PaneHeader({
201
219
  const fill = "─".repeat(fillLen);
202
220
 
203
221
  return (
204
- <Box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
222
+ <Box
223
+ ref={terminalHeaderRef}
224
+ height={PANE_HEADER_HEIGHT}
225
+ width={width}
226
+ backgroundColor={backgroundColor}
227
+ flexDirection="row"
228
+ onMouseDown={handleTerminalHeaderMouseDown}
229
+ onMouseDrag={onHeaderMouseDrag}
230
+ onMouseDragEnd={onHeaderMouseDragEnd}
231
+ >
205
232
  <Text fg={borderColor} selectable={false}>{"┌─"}</Text>
206
233
  <Text fg={textColor} selectable={false}>{`${PANE_HEADER_GRIP}${clippedTitle}`}</Text>
207
234
  <Text fg={borderColor} selectable={false}>{fill}</Text>
@@ -229,7 +256,16 @@ export function PaneHeader({
229
256
  const padding = " ".repeat(Math.max(0, titleWidth - clippedTitle.length));
230
257
 
231
258
  return (
232
- <Box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
259
+ <Box
260
+ ref={terminalHeaderRef}
261
+ height={PANE_HEADER_HEIGHT}
262
+ width={width}
263
+ backgroundColor={backgroundColor}
264
+ flexDirection="row"
265
+ onMouseDown={handleTerminalHeaderMouseDown}
266
+ onMouseDrag={onHeaderMouseDrag}
267
+ onMouseDragEnd={onHeaderMouseDragEnd}
268
+ >
233
269
  <Text fg={textColor} selectable={false}>
234
270
  {`${PANE_HEADER_GRIP}${clippedTitle}${padding}`}
235
271
  </Text>
@@ -49,9 +49,11 @@ export function PaneWrapper({
49
49
  const bg = paneBg(focused);
50
50
  const showFooter = hasPaneFooterContent(footer);
51
51
  const reserveFooter = !!title && shouldReservePaneFooter(nativePaneChrome, showFooter);
52
+ const renderFooter = !!title && (reserveFooter || showFooter);
52
53
  const bodyFrame = resolvePaneBodyFrame({
53
54
  height: typeof height === "number" ? height : undefined,
54
55
  nativePaneChrome,
56
+ footerVisible: renderFooter,
55
57
  reserveFooter,
56
58
  headerRows: title ? 1 : 0,
57
59
  });
@@ -93,7 +95,7 @@ export function PaneWrapper({
93
95
  <PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={bg}>
94
96
  {children}
95
97
  </PaneBodyFrame>
96
- {title && reserveFooter && (
98
+ {renderFooter && (
97
99
  <PaneFooterBar
98
100
  footer={footer}
99
101
  focused={focused}
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolvePaneBodyFrame, shouldReservePaneFooter } from "./sizing";
3
+
4
+ describe("pane sizing", () => {
5
+ test("lets native pane chrome lay out footer bars in normal flex flow", () => {
6
+ expect(shouldReservePaneFooter(true, true)).toBe(false);
7
+
8
+ const bodyFrame = resolvePaneBodyFrame({
9
+ width: 80,
10
+ height: 30,
11
+ nativePaneChrome: true,
12
+ footerVisible: true,
13
+ reserveFooter: false,
14
+ });
15
+
16
+ expect(bodyFrame.height).toBe(28);
17
+ expect(bodyFrame.layoutProps).toEqual({
18
+ flexGrow: 1,
19
+ flexShrink: 1,
20
+ flexBasis: 0,
21
+ minWidth: 0,
22
+ minHeight: 0,
23
+ });
24
+ });
25
+
26
+ test("keeps terminal pane footer rows reserved for border rendering", () => {
27
+ expect(shouldReservePaneFooter(false, false)).toBe(true);
28
+
29
+ const bodyFrame = resolvePaneBodyFrame({
30
+ width: 80,
31
+ height: 30,
32
+ nativePaneChrome: false,
33
+ reserveFooter: true,
34
+ });
35
+
36
+ expect(bodyFrame.height).toBe(28);
37
+ expect(bodyFrame.layoutProps).toEqual({
38
+ height: 28,
39
+ flexGrow: 0,
40
+ flexBasis: undefined,
41
+ });
42
+ });
43
+ });