gloomberb 0.9.2 → 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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -41,6 +41,7 @@ interface UseShellTerminalPointerRuntimeOptions {
41
41
  selectWindowModePane: (paneId: string) => void;
42
42
  setHoveredMenuItemId: Dispatch<SetStateAction<string | null>>;
43
43
  setMenuState: Dispatch<SetStateAction<ActionMenuState | null>>;
44
+ transientFocusActive: boolean;
44
45
  visibleFloatingPanes: VisibleFloatingPane[];
45
46
  width: number;
46
47
  windowMode: WindowEditState | null;
@@ -79,6 +80,7 @@ export function useShellTerminalPointerRuntime({
79
80
  selectWindowModePane,
80
81
  setHoveredMenuItemId,
81
82
  setMenuState,
83
+ transientFocusActive,
82
84
  visibleFloatingPanes,
83
85
  width,
84
86
  windowMode,
@@ -132,25 +134,27 @@ export function useShellTerminalPointerRuntime({
132
134
  return;
133
135
  }
134
136
 
135
- for (const divider of dockDividerLayouts) {
136
- if (!pointInRect(divider.rect, event.x, shellY)) continue;
137
- dragRef.current = {
138
- type: "divider",
139
- path: divider.path,
140
- axis: divider.axis,
141
- startX: preciseX,
142
- startY: preciseShellY,
143
- startRatio: divider.ratio,
144
- bounds: divider.bounds,
145
- };
146
- updateDividerPreview({
147
- pathKey: divider.path.join("."),
148
- rect: divider.rect,
149
- ratio: divider.ratio,
150
- });
151
- event.stopPropagation();
152
- event.preventDefault();
153
- return;
137
+ if (!transientFocusActive) {
138
+ for (const divider of dockDividerLayouts) {
139
+ if (!pointInRect(divider.rect, event.x, shellY)) continue;
140
+ dragRef.current = {
141
+ type: "divider",
142
+ path: divider.path,
143
+ axis: divider.axis,
144
+ startX: preciseX,
145
+ startY: preciseShellY,
146
+ startRatio: divider.ratio,
147
+ bounds: divider.bounds,
148
+ };
149
+ updateDividerPreview({
150
+ pathKey: divider.path.join("."),
151
+ rect: divider.rect,
152
+ ratio: divider.ratio,
153
+ });
154
+ event.stopPropagation();
155
+ event.preventDefault();
156
+ return;
157
+ }
154
158
  }
155
159
 
156
160
  for (const leaf of dockLeafLayouts) {
@@ -279,6 +283,11 @@ export function useShellTerminalPointerRuntime({
279
283
  event.preventDefault();
280
284
  return;
281
285
  }
286
+ if (relativeY === 0 && transientFocusActive) {
287
+ event.stopPropagation();
288
+ event.preventDefault();
289
+ return;
290
+ }
282
291
  if (relativeY === 0) {
283
292
  dragRef.current = {
284
293
  type: "pane-drag",
@@ -317,6 +326,7 @@ export function useShellTerminalPointerRuntime({
317
326
  selectWindowModePane,
318
327
  setHoveredMenuItemId,
319
328
  setMenuState,
329
+ transientFocusActive,
320
330
  updateDividerPreview,
321
331
  updateDragFloatingRect,
322
332
  visibleFloatingPanes,
@@ -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
+ }
@@ -13,6 +13,7 @@ import {
13
13
  applyWindowEditDirection,
14
14
  cycleWindowEditFocus,
15
15
  cycleWindowEditPane,
16
+ cycleWindowEditTarget,
16
17
  directionFromWindowEditKey,
17
18
  getWindowEditPaneIds,
18
19
  normalizeWindowEditFocus,
@@ -72,7 +73,10 @@ export function useShellWindowMode({
72
73
  );
73
74
  const windowModePaneId = windowMode?.paneId;
74
75
  const activeLayout = windowMode?.previewLayout ?? visibleLayout;
75
- const windowModePaneIds = useMemo(() => getWindowEditPaneIds(activeLayout), [activeLayout]);
76
+ const windowModePaneIds = useMemo(
77
+ () => getWindowEditPaneIds(activeLayout, bounds, dockGeometryOptions),
78
+ [activeLayout, bounds, dockGeometryOptions],
79
+ );
76
80
  const windowModeDockMovePreview = useMemo(
77
81
  () => resolveWindowEditDockMovePreview(windowMode, bounds, dockGeometryOptions),
78
82
  [bounds, dockGeometryOptions, windowMode],
@@ -210,12 +214,16 @@ export function useShellWindowMode({
210
214
  }
211
215
  } else if (name === "w") {
212
216
  setWindowMode((current) => current
213
- ? cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1)
217
+ ? current.mode === "move" && current.focus.kind === "dock-move"
218
+ ? cycleWindowEditTarget(current, bounds, dockGeometryOptions, event.shift ? -1 : 1)
219
+ : cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1)
214
220
  : current);
215
221
  } else if (name === "tab") {
216
222
  setWindowMode((current) => current
217
223
  ? current.mode === "move"
218
- ? cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1)
224
+ ? current.focus.kind === "dock-move"
225
+ ? cycleWindowEditTarget(current, bounds, dockGeometryOptions, event.shift ? -1 : 1)
226
+ : cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1)
219
227
  : {
220
228
  ...current,
221
229
  focus: cycleWindowEditFocus(
@@ -40,6 +40,13 @@ interface ShellWindowModeOverlaysProps {
40
40
  windowModeDockMovePreview: WindowEditDockMovePreview | null;
41
41
  }
42
42
 
43
+ function getHighlightedPaneId(windowMode: WindowEditState | null, focusedPaneId: string | null): string | null {
44
+ if (windowMode?.mode === "move" && windowMode.focus.kind === "dock-move") {
45
+ return windowMode.focus.targetId;
46
+ }
47
+ return windowMode?.paneId ?? focusedPaneId;
48
+ }
49
+
43
50
  function windowModeBannerKey(windowMode: WindowEditState): string {
44
51
  const focusKey = windowMode.focus.kind === "dock-move"
45
52
  ? `${windowMode.focus.targetId}:${windowMode.focus.position}`
@@ -69,7 +76,7 @@ export function ShellWindowModeOverlays({
69
76
  windowMode,
70
77
  windowModeDockMovePreview,
71
78
  }: ShellWindowModeOverlaysProps) {
72
- const highlightedPaneId = windowMode?.paneId ?? focusedPaneId;
79
+ const highlightedPaneId = getHighlightedPaneId(windowMode, focusedPaneId);
73
80
  const highlightedFloatingPane = highlightedPaneId
74
81
  ? visibleFloatingPanes.find((entry) => entry.pane.instance.instanceId === highlightedPaneId)
75
82
  : null;
@@ -1,10 +1,12 @@
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";
8
+ import { useEffect, useState } from "react";
9
+ import { TransientLayoutProvider, useTransientLayout } from "./transient-layout";
8
10
 
9
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
12
 
@@ -17,6 +19,37 @@ afterEach(() => {
17
19
  });
18
20
 
19
21
  describe("StatusBar", () => {
22
+ function SeedTransientLayout({
23
+ onActivate,
24
+ onDeactivate,
25
+ onExit,
26
+ }: {
27
+ onActivate?: () => void;
28
+ onDeactivate?: () => void;
29
+ onExit?: () => void;
30
+ }) {
31
+ const { setTransientLayout } = useTransientLayout();
32
+ const [active, setActive] = useState(true);
33
+ useEffect(() => {
34
+ setTransientLayout({
35
+ id: "pane-focus",
36
+ label: "^F Focus",
37
+ active,
38
+ onActivate: () => {
39
+ onActivate?.();
40
+ setActive(true);
41
+ },
42
+ onDeactivate: () => {
43
+ onDeactivate?.();
44
+ setActive(false);
45
+ },
46
+ onExit,
47
+ });
48
+ return () => setTransientLayout(null);
49
+ }, [active, onActivate, onDeactivate, onExit, setTransientLayout]);
50
+ return null;
51
+ }
52
+
20
53
  test("opens the command bar from the shortcut hint", async () => {
21
54
  const config = createDefaultConfig("/tmp/gloomberb-test");
22
55
  config.layouts = [{ name: "Home", layout: cloneLayout(config.layout) }];
@@ -45,6 +78,78 @@ describe("StatusBar", () => {
45
78
  expect(actions).toContainEqual({ type: "SET_COMMAND_BAR", open: true, query: "" });
46
79
  });
47
80
 
81
+ test("shows a transient focus layout tab without replacing saved layouts", async () => {
82
+ const config = createDefaultConfig("/tmp/gloomberb-transient-layout-test");
83
+ config.layouts = [
84
+ { name: "Default", layout: cloneLayout(config.layout) },
85
+ { name: "Monitor", layout: cloneLayout(config.layout) },
86
+ ];
87
+ const state = {
88
+ ...createInitialState(config),
89
+ statusBarVisible: true,
90
+ };
91
+ const actions: Array<{ type: string; index?: number }> = [];
92
+ let activateCount = 0;
93
+ let deactivateCount = 0;
94
+ let exitCount = 0;
95
+ const handleActivate = () => { activateCount += 1; };
96
+ const handleDeactivate = () => { deactivateCount += 1; };
97
+ const handleExit = () => { exitCount += 1; };
98
+
99
+ testSetup = await testRender(
100
+ <AppContext value={{ state, dispatch: (action) => actions.push(action as { type: string; index?: number }) }}>
101
+ <TransientLayoutProvider>
102
+ <SeedTransientLayout
103
+ onActivate={handleActivate}
104
+ onDeactivate={handleDeactivate}
105
+ onExit={handleExit}
106
+ />
107
+ <StatusBar />
108
+ </TransientLayoutProvider>
109
+ </AppContext>,
110
+ { width: 120, height: 1 },
111
+ );
112
+
113
+ await testSetup.renderOnce();
114
+ await testSetup.renderOnce();
115
+
116
+ const frame = testSetup.captureCharFrame();
117
+ expect(frame).toContain("^1 Default");
118
+ expect(frame).toContain("^2 Monitor");
119
+ expect(frame).toContain("^F Focus");
120
+
121
+ const monitorX = frame.split("\n")[0]?.indexOf("^2 Monitor") ?? -1;
122
+ expect(monitorX).toBeGreaterThanOrEqual(0);
123
+
124
+ await testSetup.mockMouse.click(monitorX + 1, 0);
125
+ await testSetup.renderOnce();
126
+ await testSetup.renderOnce();
127
+
128
+ expect(deactivateCount).toBe(1);
129
+ expect(exitCount).toBe(0);
130
+ expect(actions).toContainEqual({ type: "SWITCH_LAYOUT", index: 1 });
131
+
132
+ const afterSwitchFrame = testSetup.captureCharFrame();
133
+ expect(afterSwitchFrame).toContain("^F Focus");
134
+
135
+ const focusX = afterSwitchFrame.split("\n")[0]?.indexOf("^F Focus") ?? -1;
136
+ expect(focusX).toBeGreaterThanOrEqual(0);
137
+
138
+ await testSetup.mockMouse.click(focusX + 1, 0);
139
+ await testSetup.renderOnce();
140
+
141
+ expect(activateCount).toBe(1);
142
+
143
+ const activeFocusFrame = testSetup.captureCharFrame();
144
+ const activeFocusX = activeFocusFrame.split("\n")[0]?.indexOf("^F Focus") ?? -1;
145
+ expect(activeFocusX).toBeGreaterThanOrEqual(0);
146
+
147
+ await testSetup.mockMouse.click(activeFocusX + 1, 0);
148
+ await testSetup.renderOnce();
149
+
150
+ expect(exitCount).toBe(1);
151
+ });
152
+
48
153
  test("shows a gridlock tip after a corner snap and runs gridlock on click", async () => {
49
154
  const config = createDefaultConfig("/tmp/gloomberb-test");
50
155
  const floatingLayout = cloneLayout(config.layout);
@@ -69,6 +174,10 @@ describe("StatusBar", () => {
69
174
  const notifications: AppNotificationRequest[] = [];
70
175
 
71
176
  setSharedRegistryForTests({
177
+ panes: new Map([
178
+ ["portfolio-list", {}],
179
+ [TICKER_RESEARCH_PANE_ID, {}],
180
+ ]),
72
181
  getLayoutFn: () => state.config.layout,
73
182
  getTermSizeFn: () => ({ width: 120, height: 40 }),
74
183
  updateLayoutFn: (layout: LayoutConfig) => { updatedLayout = layout; },
@@ -128,6 +237,7 @@ describe("StatusBar", () => {
128
237
  globalThis.clearTimeout = (() => {}) as typeof clearTimeout;
129
238
 
130
239
  setSharedRegistryForTests({
240
+ panes: new Map(),
131
241
  getLayoutFn: () => state.config.layout,
132
242
  getTermSizeFn: () => ({ width: 120, height: 40 }),
133
243
  updateLayoutFn: () => {},
@@ -16,6 +16,7 @@ import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
16
16
  import { PluginSlot } from "../../react/plugins/plugin-slot";
17
17
  import type { ContextMenuItem } from "../../types/context-menu";
18
18
  import { Tabs } from "../ui/tabs";
19
+ import { useTransientLayout } from "./transient-layout";
19
20
 
20
21
  const GRIDLOCK_TIP_DURATION_MS = 60_000;
21
22
 
@@ -31,6 +32,7 @@ type LayoutTabItem = {
31
32
 
32
33
  type StatusBarViewProps = {
33
34
  activeLayoutIdx: number;
35
+ activeLayoutValue: string;
34
36
  dismissGridlockTip: (event?: StatusBarEvent) => void;
35
37
  handleGridlockTip: (event?: StatusBarEvent) => void;
36
38
  handleLayoutSelect: (value: string) => void;
@@ -62,18 +64,40 @@ export function StatusBar() {
62
64
  const statusBarVisible = useAppSelector(selectStatusBarVisible);
63
65
  const gridlockTipVisible = useAppSelector(selectGridlockTipVisible);
64
66
  const gridlockTipSequence = useAppSelector(selectGridlockTipSequence);
67
+ const { transientLayout } = useTransientLayout();
65
68
  const [hoveredControl, setHoveredControl] = useState<string | null>(null);
66
69
 
67
- const hasMultipleLayouts = layouts.length > 1;
70
+ const hasMultipleLayouts = layouts.length > 1 || !!transientLayout;
68
71
  const showGridlockTip = gridlockTipVisible && !!registry;
69
- const layoutTabs = layouts.map((layout, index) => ({
72
+ const savedLayoutTabs = layouts.map((layout, index) => ({
70
73
  label: `^${index + 1} ${truncate(layout.name, 14)}`,
71
74
  value: String(index),
72
75
  }));
76
+ const layoutTabs = transientLayout
77
+ ? [
78
+ ...savedLayoutTabs,
79
+ {
80
+ label: transientLayout.label,
81
+ value: transientLayout.id,
82
+ },
83
+ ]
84
+ : savedLayoutTabs;
73
85
  const layoutTabsWidth = layoutTabs.reduce((sum, tab) => sum + tab.label.length + 2, 0);
86
+ const activeLayoutValue = transientLayout?.active ? transientLayout.id : String(activeLayoutIdx);
74
87
  const handleLayoutSelect = (value: string) => {
88
+ if (value === transientLayout?.id) {
89
+ if (transientLayout.active) {
90
+ transientLayout.onExit?.();
91
+ } else {
92
+ transientLayout.onActivate?.();
93
+ }
94
+ return;
95
+ }
75
96
  const index = Number(value);
76
97
  if (!Number.isInteger(index) || index < 0 || index >= layouts.length) return;
98
+ if (transientLayout?.active) {
99
+ transientLayout.onDeactivate?.();
100
+ }
77
101
  dispatch({ type: "SWITCH_LAYOUT", index });
78
102
  };
79
103
 
@@ -90,7 +114,11 @@ export function StatusBar() {
90
114
  event?.stopPropagation?.();
91
115
  if (!registry) return;
92
116
  const { width, height } = registry.getTermSizeFn();
93
- 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
+ ));
94
122
  notifyGridlockComplete(registry.notify.bind(registry), () => {
95
123
  dispatch({ type: "UNDO_LAYOUT" });
96
124
  });
@@ -184,11 +212,12 @@ export function StatusBar() {
184
212
  );
185
213
  }, [activeLayoutIdx, layoutContextMenuItems, layouts, showContextMenu]);
186
214
  const handleLayoutTabContextMenu = useCallback((value: string, event: any) => {
215
+ if (value === transientLayout?.id) return;
187
216
  const index = Number(value);
188
217
  if (!Number.isInteger(index) || index < 0 || index >= layouts.length) return;
189
218
  if (event?.type !== "contextmenu" && event?.button === 2 && nativeContextMenu === true) return;
190
219
  void openLayoutContextMenu(index, event);
191
- }, [layouts.length, nativeContextMenu, openLayoutContextMenu]);
220
+ }, [layouts.length, nativeContextMenu, openLayoutContextMenu, transientLayout?.id]);
192
221
  const layoutTabItems = layoutTabs.map((tab) => ({
193
222
  ...tab,
194
223
  onContextMenu: handleLayoutTabContextMenu,
@@ -198,6 +227,7 @@ export function StatusBar() {
198
227
 
199
228
  const viewProps: StatusBarViewProps = {
200
229
  activeLayoutIdx,
230
+ activeLayoutValue,
201
231
  dismissGridlockTip,
202
232
  handleGridlockTip,
203
233
  handleLayoutSelect,
@@ -272,7 +302,7 @@ function TerminalStatusBar({
272
302
  }
273
303
 
274
304
  function StatusBarLayoutControl({
275
- activeLayoutIdx,
305
+ activeLayoutValue,
276
306
  handleLayoutSelect,
277
307
  hasMultipleLayouts,
278
308
  hoveredControl,
@@ -283,7 +313,7 @@ function StatusBarLayoutControl({
283
313
  setHoveredControl,
284
314
  }: Pick<
285
315
  StatusBarViewProps,
286
- | "activeLayoutIdx"
316
+ | "activeLayoutValue"
287
317
  | "handleLayoutSelect"
288
318
  | "hasMultipleLayouts"
289
319
  | "hoveredControl"
@@ -303,7 +333,7 @@ function StatusBarLayoutControl({
303
333
  <Box width={layoutTabsWidth} height={1}>
304
334
  <Tabs
305
335
  tabs={layoutTabItems}
306
- activeValue={String(activeLayoutIdx)}
336
+ activeValue={activeLayoutValue}
307
337
  onSelect={handleLayoutSelect}
308
338
  compact
309
339
  variant="pill"
@@ -0,0 +1,57 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
2
+
3
+ export interface TransientLayoutState {
4
+ id: "pane-focus";
5
+ label: string;
6
+ active: boolean;
7
+ onActivate?: () => void;
8
+ onDeactivate?: () => void;
9
+ onExit?: () => void;
10
+ }
11
+
12
+ interface TransientLayoutContextValue {
13
+ transientLayout: TransientLayoutState | null;
14
+ setTransientLayout: (layout: TransientLayoutState | null) => void;
15
+ }
16
+
17
+ const noop = () => {};
18
+
19
+ const TransientLayoutContext = createContext<TransientLayoutContextValue>({
20
+ transientLayout: null,
21
+ setTransientLayout: noop,
22
+ });
23
+
24
+ function sameTransientLayout(
25
+ left: TransientLayoutState | null,
26
+ right: TransientLayoutState | null,
27
+ ): boolean {
28
+ if (left === right) return true;
29
+ if (!left || !right) return false;
30
+ return left.id === right.id
31
+ && left.label === right.label
32
+ && left.active === right.active
33
+ && left.onActivate === right.onActivate
34
+ && left.onDeactivate === right.onDeactivate
35
+ && left.onExit === right.onExit;
36
+ }
37
+
38
+ export function TransientLayoutProvider({ children }: { children: ReactNode }) {
39
+ const [transientLayout, setTransientLayoutState] = useState<TransientLayoutState | null>(null);
40
+ const setTransientLayout = useCallback((layout: TransientLayoutState | null) => {
41
+ setTransientLayoutState((current) => (sameTransientLayout(current, layout) ? current : layout));
42
+ }, []);
43
+ const value = useMemo(() => ({
44
+ transientLayout,
45
+ setTransientLayout,
46
+ }), [setTransientLayout, transientLayout]);
47
+
48
+ return (
49
+ <TransientLayoutContext.Provider value={value}>
50
+ {children}
51
+ </TransientLayoutContext.Provider>
52
+ );
53
+ }
54
+
55
+ export function useTransientLayout(): TransientLayoutContextValue {
56
+ return useContext(TransientLayoutContext);
57
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { LayoutConfig } from "../../../types/config";
3
- import { cycleWindowEditPane, getWindowEditPaneIds, type WindowEditState } from "./mode";
3
+ import {
4
+ cycleWindowEditPane,
5
+ cycleWindowEditTarget,
6
+ getWindowEditPaneIds,
7
+ type WindowEditState,
8
+ } from "./mode";
9
+ import { resolveWindowEditDockMovePreview } from "./presentation";
4
10
 
5
11
  const bounds = { x: 0, y: 0, width: 120, height: 40 };
6
12
 
@@ -38,18 +44,83 @@ describe("window edit mode", () => {
38
44
  dirty: false,
39
45
  };
40
46
 
41
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
47
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
42
48
  expect(state.paneId).toBe("dock-b");
43
49
 
44
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
50
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
45
51
  expect(state.paneId).toBe("float-a");
46
52
  expect(state.previewLayout.floating.find((entry) => entry.instanceId === "float-a")?.zIndex)
47
53
  .toBeGreaterThan(state.previewLayout.floating.find((entry) => entry.instanceId === "float-b")?.zIndex ?? 0);
48
54
 
49
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
55
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
50
56
  expect(state.paneId).toBe("float-b");
51
57
 
52
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
58
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
53
59
  expect(state.paneId).toBe("dock-a");
54
60
  });
61
+
62
+ test("cycles dock move targets without changing the selected window", () => {
63
+ const layout: LayoutConfig = {
64
+ dockRoot: {
65
+ kind: "split",
66
+ axis: "horizontal",
67
+ ratio: 0.5,
68
+ first: { kind: "pane", instanceId: "source" },
69
+ second: {
70
+ kind: "split",
71
+ axis: "vertical",
72
+ ratio: 0.5,
73
+ first: { kind: "pane", instanceId: "target-a" },
74
+ second: { kind: "pane", instanceId: "target-b" },
75
+ },
76
+ },
77
+ instances: [
78
+ pane("source"),
79
+ pane("target-a"),
80
+ pane("target-b"),
81
+ ],
82
+ floating: [],
83
+ detached: [],
84
+ };
85
+ const state: WindowEditState = {
86
+ paneId: "source",
87
+ previewLayout: layout,
88
+ mode: "move",
89
+ focus: { kind: "dock-move", targetId: "target-a", position: "right" },
90
+ dirty: false,
91
+ };
92
+
93
+ const next = cycleWindowEditTarget(state, bounds, { reserveDividerGutters: true }, 1);
94
+
95
+ expect(next.paneId).toBe("source");
96
+ expect(next.focus).toEqual({ kind: "dock-move", targetId: "target-b", position: "right" });
97
+ });
98
+
99
+ test("uses dock geometry options for dock move preview rectangles", () => {
100
+ const layout: LayoutConfig = {
101
+ dockRoot: {
102
+ kind: "split",
103
+ axis: "horizontal",
104
+ ratio: 0.5,
105
+ first: { kind: "pane", instanceId: "left" },
106
+ second: { kind: "pane", instanceId: "right" },
107
+ },
108
+ instances: [
109
+ pane("left"),
110
+ pane("right"),
111
+ ],
112
+ floating: [],
113
+ detached: [],
114
+ };
115
+ const state: WindowEditState = {
116
+ paneId: "left",
117
+ previewLayout: layout,
118
+ mode: "move",
119
+ focus: { kind: "dock-move", targetId: "right", position: "right" },
120
+ dirty: false,
121
+ };
122
+
123
+ expect(resolveWindowEditDockMovePreview(state, bounds, { reserveDividerGutters: true })?.rect)
124
+ .toEqual({ x: 61, y: 0, width: 59, height: 40 });
125
+ });
55
126
  });
@@ -226,12 +226,57 @@ export function cycleWindowEditFocus(
226
226
  return targets[(currentIndex + delta + targets.length) % targets.length] ?? targets[0]!;
227
227
  }
228
228
 
229
- export function getWindowEditPaneIds(layout: LayoutConfig): string[] {
230
- const dockedIds = getDockLeafLayouts(layout, { x: 0, y: 0, width: 120, height: 40 }).map((entry) => entry.instanceId);
229
+ function getWindowEditDockPaneIds(
230
+ layout: LayoutConfig,
231
+ bounds: LayoutBounds,
232
+ dockGeometryOptions: DockGeometryOptions,
233
+ ): string[] {
234
+ return getDockLeafLayouts(layout, bounds, dockGeometryOptions).map((entry) => entry.instanceId);
235
+ }
236
+
237
+ export function getWindowEditPaneIds(
238
+ layout: LayoutConfig,
239
+ bounds: LayoutBounds,
240
+ dockGeometryOptions: DockGeometryOptions,
241
+ ): string[] {
242
+ const dockedIds = getWindowEditDockPaneIds(layout, bounds, dockGeometryOptions);
231
243
  const floatingIds = layout.floating.map((entry) => entry.instanceId);
232
244
  return [...dockedIds, ...floatingIds];
233
245
  }
234
246
 
247
+ export function cycleWindowEditTarget(
248
+ state: WindowEditState,
249
+ bounds: LayoutBounds,
250
+ dockGeometryOptions: DockGeometryOptions,
251
+ delta: 1 | -1,
252
+ ): WindowEditState {
253
+ if (state.mode !== "move" || state.focus.kind !== "dock-move") {
254
+ return state;
255
+ }
256
+
257
+ const targetIds = getWindowEditDockPaneIds(state.previewLayout, bounds, dockGeometryOptions)
258
+ .filter((paneId) => paneId !== state.paneId);
259
+ if (targetIds.length === 0) {
260
+ return {
261
+ ...state,
262
+ focus: normalizeWindowEditFocus({ kind: "move" }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
263
+ };
264
+ }
265
+
266
+ const currentIndex = targetIds.indexOf(state.focus.targetId);
267
+ const startIndex = currentIndex >= 0
268
+ ? currentIndex
269
+ : delta === 1
270
+ ? -1
271
+ : 0;
272
+ const targetId = targetIds[(startIndex + delta + targetIds.length) % targetIds.length] ?? targetIds[0]!;
273
+ return {
274
+ ...state,
275
+ focus: normalizeWindowEditFocus({ ...state.focus, targetId }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
276
+ notice: undefined,
277
+ };
278
+ }
279
+
235
280
  export function raiseWindowEditPane(layout: LayoutConfig, paneId: string): LayoutConfig {
236
281
  const floating = layout.floating.find((entry) => entry.instanceId === paneId);
237
282
  if (!floating) return layout;
@@ -73,7 +73,7 @@ export function windowEditStatusLine(
73
73
  export function windowEditHelpText(state: WindowEditState): string {
74
74
  if (state.mode === "move") {
75
75
  if (state.focus.kind === "dock-move") {
76
- return "arrows/hjkl choose side m retarget Tab/w window d float r resize Enter commit/exit Esc exit/cancel";
76
+ return "arrows/hjkl choose side Tab/w target m window d float r resize Enter commit/exit Esc exit/cancel";
77
77
  }
78
78
  return "Tab/w window d dock/float r resize arrows/hjkl target/move Shift fast Enter commit/exit Esc exit/cancel";
79
79
  }
@@ -100,6 +100,7 @@ export function resolveWindowEditDockMovePreview(
100
100
  state.paneId,
101
101
  leafDropTarget(focus.targetId, focus.position),
102
102
  bounds,
103
+ dockGeometryOptions,
103
104
  );
104
105
  if (!simulation.previewRect) return null;
105
106
  return {