gloomberb 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  29. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  30. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  31. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  32. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  33. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  34. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  35. package/src/components/chart/core/pane-settings.test.ts +27 -0
  36. package/src/components/chart/core/pane-settings.ts +60 -1
  37. package/src/components/chart/core/pointer.ts +2 -0
  38. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  39. package/src/components/chart/stock-chart/settings.ts +7 -3
  40. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  41. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  42. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  43. package/src/components/data-table/view.tsx +13 -2
  44. package/src/components/feed-data-table/stack-view.tsx +9 -1
  45. package/src/components/index.ts +1 -1
  46. package/src/components/input-search-bar.test.tsx +103 -0
  47. package/src/components/input-search-bar.tsx +16 -0
  48. package/src/components/layout/shell/active-drag.ts +5 -1
  49. package/src/components/layout/shell/drag/runtime.ts +21 -3
  50. package/src/components/layout/shell/fullscreen.ts +10 -0
  51. package/src/components/layout/shell/index.test.tsx +395 -2
  52. package/src/components/layout/shell/index.tsx +128 -2
  53. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  54. package/src/components/layout/shell/pane/layers.tsx +26 -14
  55. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  56. package/src/components/layout/shell/shortcuts.ts +4 -0
  57. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  58. package/src/components/layout/shell/window-mode/index.ts +11 -3
  59. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  60. package/src/components/layout/status-bar.test.tsx +105 -0
  61. package/src/components/layout/status-bar.tsx +32 -6
  62. package/src/components/layout/transient-layout.tsx +57 -0
  63. package/src/components/layout/window-edit/mode.test.ts +76 -5
  64. package/src/components/layout/window-edit/mode.ts +47 -2
  65. package/src/components/layout/window-edit/presentation.ts +2 -1
  66. package/src/components/price-performance.tsx +9 -1
  67. package/src/core/app-services.ts +7 -4
  68. package/src/data/config/store/index.ts +1 -2
  69. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  70. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  71. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  72. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  73. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  74. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  75. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  76. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  77. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  78. package/src/plugins/builtin/shared/read-state.ts +43 -0
  79. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  80. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  81. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  82. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  83. package/src/plugins/builtin/substack/pane.tsx +8 -3
  84. package/src/plugins/builtin/substack/read-state.ts +55 -0
  85. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  86. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  87. package/src/plugins/pane-manager/docking.ts +9 -2
  88. package/src/plugins/pane-manager/queries.ts +8 -2
  89. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  90. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  91. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  92. package/src/plugins/prediction-markets/pane.tsx +21 -1
  93. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  94. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  95. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  97. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  98. package/src/renderers/electrobun/bun/index.ts +13 -0
  99. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  100. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  101. package/src/renderers/electrobun/view/app-services.ts +6 -4
  102. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  103. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  104. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  105. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  106. package/src/renderers/opentui/start.tsx +13 -5
  107. package/src/state/app/input-capture.ts +12 -0
  108. package/src/types/plugin.ts +28 -0
  109. package/src/utils/cli-output.ts +6 -0
  110. package/src/utils/search-focus-navigation.ts +24 -0
  111. package/src/version.ts +1 -1
@@ -1,6 +1,9 @@
1
1
  import { useCallback, useEffect, useState, type RefObject } from "react";
2
2
  import { Box, Input, Text, type InputRenderable } from "../ui";
3
3
  import { colors } from "../theme/colors";
4
+ import { useAppInputCapture } from "../state/app/input-capture";
5
+ import { useShortcut } from "../react/input";
6
+ import { isPlainArrowDown, stopSearchFocusNavigation } from "../utils/search-focus-navigation";
4
7
 
5
8
  export function InputSearchBar({
6
9
  value,
@@ -12,6 +15,7 @@ export function InputSearchBar({
12
15
  placeholder,
13
16
  debounceMs,
14
17
  normalizeValue = identity,
18
+ onNavigateDown,
15
19
  onFocus,
16
20
  onBlur,
17
21
  onQueryChange,
@@ -25,11 +29,23 @@ export function InputSearchBar({
25
29
  placeholder: string;
26
30
  debounceMs: number;
27
31
  normalizeValue?: (value: string) => string;
32
+ onNavigateDown?: () => void;
28
33
  onFocus: () => void;
29
34
  onBlur: () => void;
30
35
  onQueryChange: (query: string) => void;
31
36
  }) {
32
37
  const [draft, setDraft] = useState(value);
38
+ useAppInputCapture(focused && active);
39
+
40
+ useShortcut((event) => {
41
+ if (!onNavigateDown || !isPlainArrowDown(event)) return;
42
+ stopSearchFocusNavigation(event);
43
+ onNavigateDown();
44
+ }, {
45
+ allowEditable: true,
46
+ enabled: focused && active && !!onNavigateDown,
47
+ phase: "before",
48
+ });
33
49
 
34
50
  useEffect(() => {
35
51
  setDraft(value);
@@ -4,6 +4,7 @@ import {
4
4
  getRememberedFloatingRect,
5
5
  resizeSplitAtPath,
6
6
  simulateDrop,
7
+ type DockGeometryOptions,
7
8
  type DockLeafLayout,
8
9
  type DropTarget,
9
10
  type LayoutBounds,
@@ -32,6 +33,7 @@ interface UseShellActiveDragOptions {
32
33
  bounds: LayoutBounds;
33
34
  contentHeight: number;
34
35
  dispatch: Dispatch<AppAction>;
36
+ dockGeometryOptions: DockGeometryOptions;
35
37
  dockLeafLayouts: DockLeafLayout[];
36
38
  dragRuntime: ShellDragRuntimeState;
37
39
  focusPane: (paneId: string) => void;
@@ -51,6 +53,7 @@ export function useShellActiveDrag({
51
53
  bounds,
52
54
  contentHeight,
53
55
  dispatch,
56
+ dockGeometryOptions,
54
57
  dockLeafLayouts,
55
58
  dragRuntime,
56
59
  focusPane,
@@ -117,7 +120,7 @@ export function useShellActiveDrag({
117
120
  const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, hitX, hitShellY));
118
121
  if (hoveredCell) {
119
122
  const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
120
- const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds);
123
+ const simulation = simulateDrop(baseLayout, drag.paneId, target, bounds, dockGeometryOptions);
121
124
  if (simulation.previewRect) {
122
125
  updateDockPreview({ kind: "dock", target, rect: simulation.previewRect });
123
126
  } else {
@@ -199,6 +202,7 @@ export function useShellActiveDrag({
199
202
  bounds,
200
203
  contentHeight,
201
204
  dispatch,
205
+ dockGeometryOptions,
202
206
  dividerPreviewRef,
203
207
  dockLeafLayouts,
204
208
  dockPreviewRef,
@@ -6,8 +6,10 @@ import {
6
6
  type MutableRefObject,
7
7
  type SetStateAction,
8
8
  } from "react";
9
+ import { useRafCallback } from "../../../../react/use-raf-callback";
9
10
  import {
10
11
  type DockDividerLayout,
12
+ type DockGeometryOptions,
11
13
  type DockLeafLayout,
12
14
  type FloatingRect,
13
15
  type LayoutBounds,
@@ -91,17 +93,26 @@ export function useShellDragRuntimeState({
91
93
  }): ShellDragRuntimeState {
92
94
  const dragRef = useRef<DragMode | null>(null);
93
95
  const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
96
+ const pendingDragFloatingRectRef = useRef<{ paneId: string; rect: FloatingRect } | null>(null);
94
97
  const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
95
98
  const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
96
99
  const [dockPreview, setDockPreview] = useState<DragPreview | null>(null);
97
100
  const dividerPreviewRef = useRef<DividerPreviewState | null>(null);
98
101
  const dockPreviewRef = useRef<DragPreview | null>(null);
102
+ const flushDragFloatingRect = useRafCallback(() => {
103
+ setDragFloatingRect(pendingDragFloatingRectRef.current);
104
+ });
99
105
 
100
106
  const updateDragFloatingRect = useCallback((next: { paneId: string; rect: FloatingRect } | null) => {
101
- setDragFloatingRect(next
107
+ pendingDragFloatingRectRef.current = next
102
108
  ? { paneId: next.paneId, rect: constrainFloatingRectToBounds(next.rect, width, contentHeight) }
103
- : null);
104
- }, [contentHeight, width]);
109
+ : null;
110
+ if (!next) {
111
+ setDragFloatingRect(null);
112
+ return;
113
+ }
114
+ flushDragFloatingRect();
115
+ }, [contentHeight, flushDragFloatingRect, width]);
105
116
 
106
117
  const updateDividerPreview = useCallback((next: DividerPreviewState | null) => {
107
118
  dividerPreviewRef.current = next;
@@ -146,6 +157,7 @@ interface UseShellPointerRuntimeOptions {
146
157
  closePaneMenu: () => void;
147
158
  contentHeight: number;
148
159
  dispatch: Dispatch<AppAction>;
160
+ dockGeometryOptions: DockGeometryOptions;
149
161
  dockDividerLayouts: DockDividerLayout[];
150
162
  dockLeafLayouts: DockLeafLayout[];
151
163
  dragRuntime: ShellDragRuntimeState;
@@ -166,6 +178,7 @@ interface UseShellPointerRuntimeOptions {
166
178
  setHoveredMenuItemId: Dispatch<SetStateAction<string | null>>;
167
179
  setMenuState: Dispatch<SetStateAction<ActionMenuState | null>>;
168
180
  snapGuides: ReturnType<typeof makeSnapGuides>;
181
+ transientFocusActive: boolean;
169
182
  updateWindowModePreviewLayout: (nextLayout: LayoutConfig, paneId?: string) => void;
170
183
  visibleFloatingPanes: VisibleFloatingPane[];
171
184
  visibleLayout: LayoutConfig;
@@ -179,6 +192,7 @@ export function useShellPointerRuntime({
179
192
  closePaneMenu,
180
193
  contentHeight,
181
194
  dispatch,
195
+ dockGeometryOptions,
182
196
  dockDividerLayouts,
183
197
  dockLeafLayouts,
184
198
  dragRuntime,
@@ -195,6 +209,7 @@ export function useShellPointerRuntime({
195
209
  setHoveredMenuItemId,
196
210
  setMenuState,
197
211
  snapGuides,
212
+ transientFocusActive,
198
213
  updateWindowModePreviewLayout,
199
214
  visibleFloatingPanes,
200
215
  visibleLayout,
@@ -206,6 +221,7 @@ export function useShellPointerRuntime({
206
221
  bounds,
207
222
  contentHeight,
208
223
  dispatch,
224
+ dockGeometryOptions,
209
225
  dockLeafLayouts,
210
226
  focusPane,
211
227
  dragRuntime,
@@ -237,6 +253,7 @@ export function useShellPointerRuntime({
237
253
  selectWindowModePane,
238
254
  setHoveredMenuItemId,
239
255
  setMenuState,
256
+ transientFocusActive,
240
257
  visibleFloatingPanes,
241
258
  width,
242
259
  windowMode,
@@ -254,6 +271,7 @@ export function useShellPointerRuntime({
254
271
  selectWindowModePane,
255
272
  setHoveredMenuItemId,
256
273
  setMenuState,
274
+ transientFocusActive,
257
275
  windowMode,
258
276
  });
259
277
 
@@ -0,0 +1,10 @@
1
+ import { isPaneInLayout } from "../../../plugins/pane-manager";
2
+ import { cloneLayout, type LayoutConfig } from "../../../types/config";
3
+
4
+ export function resolvePaneFocusSourceLayout(
5
+ layout: LayoutConfig,
6
+ paneId: string | null,
7
+ ): LayoutConfig | null {
8
+ if (!paneId || !isPaneInLayout(layout, paneId)) return null;
9
+ return cloneLayout(layout);
10
+ }
@@ -2,8 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { TestDialogProvider, testRender } from "../../../renderers/opentui/test-utils";
3
3
  import { openTuiUiHost } from "../../../renderers/opentui/ui-host";
4
4
  import type { ReactNode } from "react";
5
- import { act, useReducer } from "react";
6
- import { AppContext, appReducer, createInitialState } from "../../../state/app/context";
5
+ import { act, useReducer, useState } from "react";
6
+ import {
7
+ AppContext,
8
+ appReducer,
9
+ createInitialState,
10
+ resolveTickerForPane,
11
+ usePaneTicker,
12
+ } from "../../../state/app/context";
7
13
  import { cloneLayout, createDefaultConfig, TICKER_RESEARCH_PANE_ID, type LayoutConfig } from "../../../types/config";
8
14
  import type { PluginRegistry } from "../../../plugins/registry";
9
15
  import type { PaneProps } from "../../../types/plugin";
@@ -14,6 +20,8 @@ import {
14
20
  resolveAppHeaderHeightCells,
15
21
  Shell,
16
22
  } from "./index";
23
+ import { resolvePaneFocusSourceLayout } from "./fullscreen";
24
+ import { TransientLayoutProvider, useTransientLayout, type TransientLayoutState } from "../transient-layout";
17
25
 
18
26
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
19
27
 
@@ -133,6 +141,44 @@ async function renderShellForWindowModeTest(
133
141
  return { actions, registry };
134
142
  }
135
143
 
144
+ function CaptureTransientLayout({
145
+ controls,
146
+ }: {
147
+ controls: { transientLayout: TransientLayoutState | null };
148
+ }) {
149
+ const { transientLayout } = useTransientLayout();
150
+ controls.transientLayout = transientLayout;
151
+ return null;
152
+ }
153
+
154
+ function ShellTransientHarness({
155
+ initialState,
156
+ registry,
157
+ controls,
158
+ }: {
159
+ initialState: ReturnType<typeof createInitialState>;
160
+ registry: PluginRegistry;
161
+ controls: {
162
+ dispatch?: (action: ShellTestAction) => void;
163
+ state?: ReturnType<typeof createInitialState>;
164
+ transientLayout: TransientLayoutState | null;
165
+ };
166
+ }) {
167
+ const [state, dispatch] = useReducer(appReducer, initialState);
168
+ controls.dispatch = dispatch;
169
+ controls.state = state;
170
+ return (
171
+ <AppContext value={{ state, dispatch }}>
172
+ <TransientLayoutProvider>
173
+ <TestDialogProvider>
174
+ <Shell pluginRegistry={registry} />
175
+ </TestDialogProvider>
176
+ <CaptureTransientLayout controls={controls} />
177
+ </TransientLayoutProvider>
178
+ </AppContext>
179
+ );
180
+ }
181
+
136
182
  function findUpdateLayout(actions: ShellTestAction[]) {
137
183
  return actions.find((action) => action.type === "UPDATE_LAYOUT");
138
184
  }
@@ -417,6 +463,7 @@ describe("Shell", () => {
417
463
  expect(resolvePaneManagementShortcut({ ...base, name: "o", key: "o" })).toBe("pop-out");
418
464
  expect(resolvePaneManagementShortcut({ ...base, name: "c", key: "c" })).toBe("copy-screenshot");
419
465
  expect(resolvePaneManagementShortcut({ ...base, name: "l", key: "l" })).toBe("layout-actions");
466
+ expect(resolvePaneManagementShortcut({ ...base, name: "f", key: "f" })).toBe("toggle-fullscreen");
420
467
  expect(resolvePaneManagementShortcut({ ...base, name: "g", key: "g" })).toBe("gridlock-all");
421
468
  expect(resolvePaneManagementShortcut({ ...base, name: "m", key: "m" })).toBe("window-mode");
422
469
  expect(resolvePaneManagementShortcut({ ...base, name: "r", key: "r" })).toBe("window-resize-mode");
@@ -425,6 +472,352 @@ describe("Shell", () => {
425
472
  expect(resolvePaneManagementShortcut({ ...base, name: "d", key: "d", meta: false, super: false })).toBeNull();
426
473
  });
427
474
 
475
+ test("toggles the focused pane fullscreen without persisting layout", async () => {
476
+ const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-shortcut-test");
477
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
478
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
479
+ const dockedLayout = {
480
+ dockRoot: {
481
+ kind: "split" as const,
482
+ axis: "horizontal" as const,
483
+ ratio: 0.5,
484
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
485
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
486
+ },
487
+ instances: [{ ...mainPane }, { ...detailPane }],
488
+ floating: [],
489
+ detached: [],
490
+ };
491
+ const { actions } = await renderShellForWindowModeTest(
492
+ createShellStateWithLayout(config, dockedLayout, "portfolio-list:main"),
493
+ { width: 80, height: 18 },
494
+ );
495
+
496
+ expect(testSetup.captureCharFrame()).toContain("Main Portfolio");
497
+ expect(testSetup.captureCharFrame()).toContain("Ticker Research Body");
498
+
499
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
500
+ let frame = testSetup.captureCharFrame();
501
+ expect(frame).toContain("Main Portfolio");
502
+ expect(frame).not.toContain("Ticker Research Body");
503
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
504
+
505
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
506
+ await act(async () => {
507
+ await testSetup!.renderOnce();
508
+ });
509
+ frame = testSetup.captureCharFrame();
510
+ expect(frame).toContain("Main Portfolio");
511
+ expect(frame).toContain("Ticker Research Body");
512
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
513
+ });
514
+
515
+ test("captures the source layout for transient pane focus", () => {
516
+ const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-layout-test");
517
+ const layout = cloneLayout(config.layout);
518
+ layout.dockRoot = { kind: "pane", instanceId: "portfolio-list:main" };
519
+ layout.floating = [{ instanceId: "ticker-detail:main", x: 8, y: 2, width: 32, height: 10, zIndex: 75 }];
520
+
521
+ const focusedLayout = resolvePaneFocusSourceLayout(layout, "ticker-detail:main");
522
+
523
+ expect(focusedLayout).toMatchObject({
524
+ dockRoot: { kind: "pane", instanceId: "portfolio-list:main" },
525
+ floating: [{ instanceId: "ticker-detail:main", x: 8, y: 2, width: 32, height: 10, zIndex: 75 }],
526
+ });
527
+ expect(focusedLayout).not.toBe(layout);
528
+ expect(focusedLayout?.instances.map((instance) => instance.instanceId)).toEqual(
529
+ layout.instances.map((instance) => instance.instanceId),
530
+ );
531
+ });
532
+
533
+ test("locks layout-changing mouse drags while fullscreen is active", async () => {
534
+ const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-drag-test");
535
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
536
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
537
+ const dockedLayout = {
538
+ dockRoot: {
539
+ kind: "split" as const,
540
+ axis: "horizontal" as const,
541
+ ratio: 0.5,
542
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
543
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
544
+ },
545
+ instances: [{ ...mainPane }, { ...detailPane }],
546
+ floating: [],
547
+ detached: [],
548
+ };
549
+ const { actions } = await renderShellForWindowModeTest(
550
+ createShellStateWithLayout(config, dockedLayout, "portfolio-list:main"),
551
+ { width: 80, height: 18 },
552
+ );
553
+
554
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
555
+ await act(async () => {
556
+ await testSetup!.mockMouse.drag(4, 0, 32, 4);
557
+ await testSetup!.renderOnce();
558
+ await testSetup!.renderOnce();
559
+ });
560
+
561
+ const frame = testSetup.captureCharFrame();
562
+ expect(frame).toContain("Main Portfolio");
563
+ expect(frame).not.toContain("Ticker Research Body");
564
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
565
+ });
566
+
567
+ test("keeps focused pane local detail state when maximizing a floating pane", async () => {
568
+ const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-local-state-test");
569
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
570
+ let openDetail: (() => void) | null = null;
571
+ const registry = createShellPluginRegistry({
572
+ tickerDetailComponent: () => {
573
+ const [detailOpen, setDetailOpen] = useState(false);
574
+ openDetail = () => setDetailOpen(true);
575
+ return <text>{detailOpen ? "Detail State" : "Table State"}</text>;
576
+ },
577
+ });
578
+ const floatingLayout = {
579
+ dockRoot: null,
580
+ instances: [{ ...detailPane }],
581
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 40, height: 10, zIndex: 50 }],
582
+ detached: [],
583
+ };
584
+ await renderShellForWindowModeTest(
585
+ createShellStateWithLayout(config, floatingLayout, "ticker-detail:main"),
586
+ { registry, width: 80, height: 18 },
587
+ );
588
+
589
+ await act(async () => {
590
+ openDetail?.();
591
+ await testSetup!.renderOnce();
592
+ });
593
+ expect(testSetup.captureCharFrame()).toContain("etail State");
594
+
595
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
596
+
597
+ const frame = testSetup.captureCharFrame();
598
+ expect(frame).toContain("etail State");
599
+ expect(frame).not.toContain("able State");
600
+ });
601
+
602
+ test("allows pane fullscreen shortcut while text input is captured on desktop", async () => {
603
+ const config = createDefaultConfig("/tmp/gloomberb-shell-fullscreen-captured-input-test");
604
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
605
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
606
+ const dockedLayout = {
607
+ dockRoot: {
608
+ kind: "split" as const,
609
+ axis: "horizontal" as const,
610
+ ratio: 0.5,
611
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
612
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
613
+ },
614
+ instances: [{ ...mainPane }, { ...detailPane }],
615
+ floating: [],
616
+ detached: [],
617
+ };
618
+ const { actions } = await renderShellForWindowModeTest(
619
+ {
620
+ ...createShellStateWithLayout(config, dockedLayout, "portfolio-list:main"),
621
+ inputCaptured: true,
622
+ },
623
+ { width: 80, height: 18 },
624
+ );
625
+
626
+ await emitKeypress({
627
+ name: "f",
628
+ key: "f",
629
+ meta: true,
630
+ super: true,
631
+ shift: true,
632
+ targetEditable: true,
633
+ } as any);
634
+
635
+ const frame = testSetup.captureCharFrame();
636
+ expect(frame).toContain("Main Portfolio");
637
+ expect(frame).not.toContain("Ticker Research Body");
638
+ expect(actions.some((action) => action.type === "UPDATE_LAYOUT")).toBe(false);
639
+ });
640
+
641
+ test("keeps the transient focus layout available after switching saved layouts", async () => {
642
+ const config = createDefaultConfig("/tmp/gloomberb-shell-transient-focus-layout-tab-test");
643
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
644
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
645
+ const defaultLayout = {
646
+ dockRoot: {
647
+ kind: "split" as const,
648
+ axis: "horizontal" as const,
649
+ ratio: 0.5,
650
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
651
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
652
+ },
653
+ instances: [{ ...mainPane }, { ...detailPane }],
654
+ floating: [],
655
+ detached: [],
656
+ };
657
+ const monitorLayout = {
658
+ dockRoot: { kind: "pane" as const, instanceId: "ticker-detail:main" },
659
+ instances: [{ ...detailPane }],
660
+ floating: [],
661
+ detached: [],
662
+ };
663
+ config.layout = cloneLayout(defaultLayout);
664
+ config.layouts = [
665
+ { name: "Default", layout: cloneLayout(defaultLayout), focusedPaneId: "portfolio-list:main" },
666
+ { name: "Monitor", layout: cloneLayout(monitorLayout), focusedPaneId: "ticker-detail:main" },
667
+ ];
668
+ config.activeLayoutIndex = 0;
669
+
670
+ const initialState = {
671
+ ...createInitialState(config),
672
+ focusedPaneId: "portfolio-list:main",
673
+ statusBarVisible: true,
674
+ };
675
+ const registry = createShellPluginRegistry();
676
+ const controls: {
677
+ dispatch?: (action: ShellTestAction) => void;
678
+ state?: ReturnType<typeof createInitialState>;
679
+ transientLayout: TransientLayoutState | null;
680
+ } = { transientLayout: null };
681
+
682
+ testSetup = await testRender(
683
+ <ShellTransientHarness initialState={initialState} registry={registry} controls={controls} />,
684
+ { width: 100, height: 18 },
685
+ );
686
+
687
+ await testSetup.renderOnce();
688
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
689
+
690
+ let frame = testSetup.captureCharFrame();
691
+ expect(frame).toContain("Main Portfolio");
692
+ expect(frame).not.toContain("Ticker Research Body");
693
+ expect(controls.transientLayout).toMatchObject({
694
+ id: "pane-focus",
695
+ active: true,
696
+ });
697
+
698
+ await act(async () => {
699
+ controls.transientLayout?.onDeactivate?.();
700
+ controls.dispatch?.({ type: "SWITCH_LAYOUT", index: 1 });
701
+ await testSetup!.renderOnce();
702
+ await testSetup!.renderOnce();
703
+ });
704
+
705
+ expect(controls.transientLayout).toMatchObject({
706
+ id: "pane-focus",
707
+ active: false,
708
+ });
709
+
710
+ await act(async () => {
711
+ controls.transientLayout?.onActivate?.();
712
+ await testSetup!.renderOnce();
713
+ await testSetup!.renderOnce();
714
+ await testSetup!.renderOnce();
715
+ await testSetup!.renderOnce();
716
+ });
717
+
718
+ expect(controls.transientLayout).toMatchObject({
719
+ id: "pane-focus",
720
+ active: true,
721
+ });
722
+ });
723
+
724
+ test("restores the focus source layout state when reactivating transient focus", async () => {
725
+ const config = createDefaultConfig("/tmp/gloomberb-shell-transient-focus-state-test");
726
+ const mainPane = requireLayoutInstance(config, "portfolio-list:main");
727
+ const detailPane = requireLayoutInstance(config, "ticker-detail:main");
728
+ const defaultLayout = {
729
+ dockRoot: {
730
+ kind: "split" as const,
731
+ axis: "horizontal" as const,
732
+ ratio: 0.5,
733
+ first: { kind: "pane" as const, instanceId: "portfolio-list:main" },
734
+ second: { kind: "pane" as const, instanceId: "ticker-detail:main" },
735
+ },
736
+ instances: [{ ...mainPane }, { ...detailPane }],
737
+ floating: [],
738
+ detached: [],
739
+ };
740
+ const monitorLayout = cloneLayout(defaultLayout);
741
+ config.layout = cloneLayout(defaultLayout);
742
+ config.layouts = [
743
+ {
744
+ name: "Default",
745
+ layout: cloneLayout(defaultLayout),
746
+ focusedPaneId: "ticker-detail:main",
747
+ paneState: {
748
+ "portfolio-list:main": { collectionId: "main", cursorSymbol: "MSTR" },
749
+ "ticker-detail:main": { activeTabId: "overview" },
750
+ },
751
+ },
752
+ {
753
+ name: "Monitor",
754
+ layout: monitorLayout,
755
+ focusedPaneId: "ticker-detail:main",
756
+ paneState: {
757
+ "portfolio-list:main": { collectionId: "main", cursorSymbol: null },
758
+ "ticker-detail:main": { activeTabId: "overview" },
759
+ },
760
+ },
761
+ ];
762
+ config.activeLayoutIndex = 0;
763
+ const registry = createShellPluginRegistry({
764
+ tickerDetailComponent: () => {
765
+ const { symbol } = usePaneTicker();
766
+ return <text>{`Ticker:${symbol ?? "none"}`}</text>;
767
+ },
768
+ });
769
+ const controls: {
770
+ dispatch?: (action: ShellTestAction) => void;
771
+ state?: ReturnType<typeof createInitialState>;
772
+ transientLayout: TransientLayoutState | null;
773
+ } = { transientLayout: null };
774
+
775
+ testSetup = await testRender(
776
+ <ShellTransientHarness
777
+ initialState={createInitialState(config)}
778
+ registry={registry}
779
+ controls={controls}
780
+ />,
781
+ { width: 100, height: 18 },
782
+ );
783
+
784
+ await testSetup.renderOnce();
785
+ expect(testSetup.captureCharFrame()).toContain("icker:MSTR");
786
+
787
+ await emitKeypress({ name: "f", ctrl: true, shift: true });
788
+ expect(testSetup.captureCharFrame()).toContain("icker:MSTR");
789
+
790
+ await act(async () => {
791
+ controls.transientLayout?.onDeactivate?.();
792
+ controls.dispatch?.({ type: "SWITCH_LAYOUT", index: 1 });
793
+ await testSetup!.renderOnce();
794
+ await testSetup!.renderOnce();
795
+ });
796
+ expect(controls.transientLayout).toMatchObject({
797
+ id: "pane-focus",
798
+ active: false,
799
+ });
800
+ expect(controls.state?.config.activeLayoutIndex).toBe(1);
801
+ expect(controls.state?.paneState["portfolio-list:main"]?.cursorSymbol).toBeNull();
802
+ expect(controls.state ? resolveTickerForPane(controls.state, "ticker-detail:main") : null).toBeNull();
803
+
804
+ await act(async () => {
805
+ controls.transientLayout?.onActivate?.();
806
+ await testSetup!.renderOnce();
807
+ await testSetup!.renderOnce();
808
+ await testSetup!.renderOnce();
809
+ await testSetup!.renderOnce();
810
+ });
811
+
812
+ expect(controls.transientLayout).toMatchObject({
813
+ id: "pane-focus",
814
+ active: true,
815
+ });
816
+ expect(controls.state?.config.activeLayoutIndex).toBe(0);
817
+ expect(controls.state?.paneState["portfolio-list:main"]?.cursorSymbol).toBe("MSTR");
818
+ expect(controls.state ? resolveTickerForPane(controls.state, "ticker-detail:main") : null).toBe("MSTR");
819
+ });
820
+
428
821
  test("exits window mode on Enter without changes", async () => {
429
822
  const config = createDefaultConfig("/tmp/gloomberb-shell-window-mode-resize-shortcut-test");
430
823
  const mainPane = requireLayoutInstance(config, "portfolio-list:main");