gloomberb 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  29. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  30. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  31. package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
  32. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  33. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  34. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  35. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  36. package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
  37. package/src/components/chart/core/pane-settings.test.ts +27 -0
  38. package/src/components/chart/core/pane-settings.ts +60 -1
  39. package/src/components/chart/core/pointer.ts +2 -0
  40. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  41. package/src/components/chart/stock-chart/settings.ts +7 -3
  42. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  43. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  44. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  45. package/src/components/data-table/view.tsx +13 -2
  46. package/src/components/feed-data-table/stack-view.tsx +9 -1
  47. package/src/components/index.ts +1 -1
  48. package/src/components/input-search-bar.test.tsx +103 -0
  49. package/src/components/input-search-bar.tsx +16 -0
  50. package/src/components/layout/detached-pane-shell.tsx +5 -2
  51. package/src/components/layout/header.tsx +13 -5
  52. package/src/components/layout/shell/active-drag.ts +5 -1
  53. package/src/components/layout/shell/drag/runtime.ts +21 -3
  54. package/src/components/layout/shell/fullscreen.ts +10 -0
  55. package/src/components/layout/shell/index.test.tsx +395 -2
  56. package/src/components/layout/shell/index.tsx +128 -2
  57. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  58. package/src/components/layout/shell/pane/layers.tsx +26 -14
  59. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  60. package/src/components/layout/shell/shortcuts.ts +4 -0
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  62. package/src/components/layout/shell/window-mode/index.ts +11 -3
  63. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  64. package/src/components/layout/status-bar.test.tsx +105 -0
  65. package/src/components/layout/status-bar.tsx +32 -6
  66. package/src/components/layout/transient-layout.tsx +57 -0
  67. package/src/components/layout/window-controls.tsx +19 -10
  68. package/src/components/layout/window-edit/mode.test.ts +76 -5
  69. package/src/components/layout/window-edit/mode.ts +47 -2
  70. package/src/components/layout/window-edit/presentation.ts +2 -1
  71. package/src/components/price-performance.tsx +183 -0
  72. package/src/core/app-services.ts +7 -4
  73. package/src/data/config/store/index.ts +1 -2
  74. package/src/market-data/performance.test.ts +54 -0
  75. package/src/market-data/performance.ts +109 -0
  76. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  77. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  78. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  79. package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
  80. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  81. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  82. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  83. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  84. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  85. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  86. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  87. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  88. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  89. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  90. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  91. package/src/plugins/builtin/shared/read-state.ts +43 -0
  92. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  93. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  94. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  95. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  96. package/src/plugins/builtin/substack/pane.tsx +8 -3
  97. package/src/plugins/builtin/substack/read-state.ts +55 -0
  98. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  99. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  100. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  102. package/src/plugins/pane-manager/docking.ts +9 -2
  103. package/src/plugins/pane-manager/queries.ts +8 -2
  104. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  105. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  106. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  107. package/src/plugins/prediction-markets/pane.tsx +21 -1
  108. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  109. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  110. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  111. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  112. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  113. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  114. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  115. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  116. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  117. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  118. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  119. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  120. package/src/renderers/electrobun/bun/index.ts +22 -2
  121. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  122. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  123. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  124. package/src/renderers/electrobun/view/app-services.ts +6 -4
  125. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  126. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  127. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  128. package/src/renderers/electrobun/view/main.tsx +2 -1
  129. package/src/renderers/electrobun/view/styles.css +9 -8
  130. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  131. package/src/renderers/opentui/start.tsx +13 -5
  132. package/src/state/app/input-capture.ts +12 -0
  133. package/src/types/plugin.ts +28 -0
  134. package/src/utils/cli-output.ts +6 -0
  135. package/src/utils/search-focus-navigation.ts +24 -0
  136. package/src/version.ts +1 -1
@@ -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");
@@ -1,5 +1,5 @@
1
1
  import { AsciiText, Box, Text, compactContextMenuItems, useContextMenu, useUiHost } from "../../../ui";
2
- import { useCallback, useMemo, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useRendererHost, useUiCapabilities } from "../../../ui";
4
4
  import { useViewport } from "../../../react/input";
5
5
  import { useDialogState } from "../../../ui/dialog";
@@ -8,6 +8,7 @@ import type { DesktopDockPreviewState, DesktopWindowBridge } from "../../../type
8
8
  import {
9
9
  getDockDividerLayouts,
10
10
  getDockLeafLayouts,
11
+ isPaneInLayout,
11
12
  type DockGeometryOptions,
12
13
  type LayoutBounds,
13
14
  type ResolvedPane,
@@ -58,6 +59,8 @@ import {
58
59
  useShellVisibleLayout,
59
60
  } from "./layout-state";
60
61
  import { useShellPaneActions } from "./pane/actions";
62
+ import { resolvePaneFocusSourceLayout } from "./fullscreen";
63
+ import { useTransientLayout } from "../transient-layout";
61
64
 
62
65
  export { resolveAppHeaderHeightCells } from "./chrome";
63
66
  export { buildNativeWindowState } from "./native/window-state";
@@ -70,6 +73,13 @@ interface ShellProps {
70
73
  commandBarNativeOccluder?: LayoutBounds | null;
71
74
  }
72
75
 
76
+ interface TransientFocusLayoutState {
77
+ paneId: string;
78
+ layout: LayoutConfig;
79
+ sourceLayoutIndex: number;
80
+ active: boolean;
81
+ }
82
+
73
83
  export function Shell({
74
84
  pluginRegistry,
75
85
  desktopWindowBridge,
@@ -86,6 +96,7 @@ export function Shell({
86
96
  const inputCaptured = useAppSelector((state) => state.inputCaptured);
87
97
  const statusBarVisible = useAppSelector(selectStatusBarVisible);
88
98
  const rendererHost = useRendererHost();
99
+ const { setTransientLayout } = useTransientLayout();
89
100
  const uiKind = useUiHost().kind;
90
101
  const shortcutDisplayMode = getShortcutDisplayMode(uiKind);
91
102
  const { nativePaneChrome = false, nativeContextMenu, precisePointer, titleBarOverlay, cellHeightPx } = useUiCapabilities();
@@ -103,6 +114,9 @@ export function Shell({
103
114
  setHoveredPaneId((current) => (current === paneId ? current : paneId));
104
115
  }, []);
105
116
  const [menuState, setMenuState] = useState<ActionMenuState | null>(null);
117
+ const [transientFocusLayoutState, setTransientFocusLayoutState] = useState<TransientFocusLayoutState | null>(null);
118
+ const transientFocusLayoutStateRef = useRef<TransientFocusLayoutState | null>(null);
119
+ transientFocusLayoutStateRef.current = transientFocusLayoutState;
106
120
  const [hoveredMenuItemId, setHoveredMenuItemId] = useState<string | null>(null);
107
121
  const closePaneMenu = useCallback(() => {
108
122
  setMenuState(null);
@@ -149,7 +163,7 @@ export function Shell({
149
163
  }, [dispatch]);
150
164
 
151
165
  const {
152
- activeLayout,
166
+ activeLayout: windowModeLayout,
153
167
  nativeWindowModePanelRect,
154
168
  selectWindowModePane,
155
169
  startWindowMode,
@@ -171,6 +185,17 @@ export function Shell({
171
185
  visibleLayout,
172
186
  width,
173
187
  });
188
+ const transientFocusActive = !windowMode && transientFocusLayoutState?.active === true;
189
+ const activeLayout = transientFocusActive && transientFocusLayoutState
190
+ ? transientFocusLayoutState.layout
191
+ : windowModeLayout;
192
+ const transientFocusPaneId = transientFocusActive ? transientFocusLayoutState?.paneId ?? null : null;
193
+
194
+ useEffect(() => {
195
+ if (!windowMode || !transientFocusLayoutState) return;
196
+ transientFocusLayoutStateRef.current = null;
197
+ setTransientFocusLayoutState(null);
198
+ }, [transientFocusLayoutState, windowMode]);
174
199
 
175
200
  const {
176
201
  dockedPanes,
@@ -211,6 +236,102 @@ export function Shell({
211
236
  visibleLayout,
212
237
  width,
213
238
  });
239
+ const setTransientFocusLayout = useCallback((next: TransientFocusLayoutState | null) => {
240
+ transientFocusLayoutStateRef.current = next;
241
+ setTransientFocusLayoutState(next);
242
+ }, []);
243
+ const activateTransientFocusState = useCallback((current: TransientFocusLayoutState) => {
244
+ closePaneMenu();
245
+ setTransientFocusLayout({ ...current, active: true });
246
+ const sourceLayout = config.layouts[current.sourceLayoutIndex]?.layout;
247
+ if (
248
+ current.sourceLayoutIndex !== config.activeLayoutIndex
249
+ && sourceLayout
250
+ && isPaneInLayout(sourceLayout, current.paneId)
251
+ ) {
252
+ dispatch({ type: "SWITCH_LAYOUT", index: current.sourceLayoutIndex });
253
+ }
254
+ focusPane(current.paneId);
255
+ }, [closePaneMenu, config.activeLayoutIndex, config.layouts, dispatch, focusPane, setTransientFocusLayout]);
256
+ const toggleFocusedPaneFullscreen = useCallback(() => {
257
+ const current = transientFocusLayoutStateRef.current;
258
+ if (current?.active) {
259
+ setTransientFocusLayout(null);
260
+ return true;
261
+ }
262
+
263
+ if (
264
+ current
265
+ && current.paneId === focusedPaneId
266
+ && current.sourceLayoutIndex === config.activeLayoutIndex
267
+ ) {
268
+ activateTransientFocusState(current);
269
+ return true;
270
+ }
271
+
272
+ const nextLayout = resolvePaneFocusSourceLayout(visibleLayout, focusedPaneId);
273
+ if (!focusedPaneId || !nextLayout) {
274
+ pluginRegistry.notify({ body: "Focus a pane to make it fullscreen", type: "info" });
275
+ return false;
276
+ }
277
+
278
+ closePaneMenu();
279
+ setTransientFocusLayout({
280
+ paneId: focusedPaneId,
281
+ layout: nextLayout,
282
+ sourceLayoutIndex: config.activeLayoutIndex,
283
+ active: true,
284
+ });
285
+ focusPane(focusedPaneId);
286
+ return true;
287
+ }, [
288
+ activateTransientFocusState,
289
+ closePaneMenu,
290
+ config.activeLayoutIndex,
291
+ focusedPaneId,
292
+ focusPane,
293
+ pluginRegistry,
294
+ setTransientFocusLayout,
295
+ visibleLayout,
296
+ ]);
297
+ const activateTransientFocusLayout = useCallback(() => {
298
+ const current = transientFocusLayoutStateRef.current;
299
+ if (!current) return;
300
+ activateTransientFocusState(current);
301
+ }, [activateTransientFocusState]);
302
+ const deactivateTransientFocusLayout = useCallback(() => {
303
+ const current = transientFocusLayoutStateRef.current;
304
+ if (!current || !current.active) return;
305
+ closePaneMenu();
306
+ setTransientFocusLayout({ ...current, active: false });
307
+ }, [closePaneMenu, setTransientFocusLayout]);
308
+ const exitTransientFocusLayout = useCallback(() => {
309
+ closePaneMenu();
310
+ setTransientFocusLayout(null);
311
+ }, [closePaneMenu, setTransientFocusLayout]);
312
+
313
+ useEffect(() => {
314
+ setTransientLayout(
315
+ transientFocusLayoutState
316
+ ? {
317
+ id: "pane-focus",
318
+ label: "^F Focus",
319
+ active: transientFocusActive,
320
+ onActivate: activateTransientFocusLayout,
321
+ onDeactivate: deactivateTransientFocusLayout,
322
+ onExit: exitTransientFocusLayout,
323
+ }
324
+ : null,
325
+ );
326
+ return () => setTransientLayout(null);
327
+ }, [
328
+ activateTransientFocusLayout,
329
+ deactivateTransientFocusLayout,
330
+ exitTransientFocusLayout,
331
+ setTransientLayout,
332
+ transientFocusActive,
333
+ transientFocusLayoutState,
334
+ ]);
214
335
 
215
336
  useShellPaneManagementShortcuts({
216
337
  cancelActiveDrag,
@@ -226,6 +347,7 @@ export function Shell({
226
347
  overlayOpen,
227
348
  popOutFocusedPane,
228
349
  startWindowMode,
350
+ toggleFocusedPaneFullscreen,
229
351
  toggleFocusedPaneFloating,
230
352
  });
231
353
 
@@ -333,6 +455,7 @@ export function Shell({
333
455
  closePaneMenu,
334
456
  contentHeight,
335
457
  dispatch,
458
+ dockGeometryOptions,
336
459
  dockDividerLayouts,
337
460
  dockLeafLayouts,
338
461
  dragRuntime,
@@ -349,6 +472,7 @@ export function Shell({
349
472
  setHoveredMenuItemId,
350
473
  setMenuState,
351
474
  snapGuides,
475
+ transientFocusActive,
352
476
  updateWindowModePreviewLayout,
353
477
  visibleFloatingPanes,
354
478
  visibleLayout,
@@ -409,6 +533,8 @@ export function Shell({
409
533
  startNativeDockedDrag={startNativeDockedDrag}
410
534
  startNativeFloatingDrag={startNativeFloatingDrag}
411
535
  startNativeFloatResize={startNativeFloatResize}
536
+ transientFocusActive={transientFocusActive}
537
+ transientFocusPaneId={transientFocusPaneId}
412
538
  visibleFloatingPanes={visibleFloatingPanes}
413
539
  width={width}
414
540
  windowModeDockResizePathKey={windowModeDockResizePathKey}