gloomberb 0.3.7 → 0.4.1

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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -31,7 +31,7 @@ function UpdateStatus() {
31
31
  <box flexDirection="row" gap={1}>
32
32
  <spinner name="dots" color={colors.headerText} />
33
33
  <text fg={colors.headerText}>
34
- Downloading v{updateAvailable?.version}... {updateProgress.percent ?? 0}%
34
+ Downloading v{updateAvailable?.version}: {updateProgress.percent ?? 0}%
35
35
  </text>
36
36
  </box>
37
37
  );
@@ -45,7 +45,7 @@ function UpdateStatus() {
45
45
  );
46
46
  }
47
47
  if (updateProgress.phase === "done") {
48
- return <text fg={colors.headerText}>Update installed — restart to apply</text>;
48
+ return <text fg={colors.headerText}>Update installed, restart to apply</text>;
49
49
  }
50
50
  if (updateProgress.phase === "error") {
51
51
  return <text fg={colors.headerText}>Update failed: {updateProgress.error}</text>;
@@ -71,7 +71,7 @@ function UpdateStatus() {
71
71
  }
72
72
  return (
73
73
  <text fg={colors.headerText}>
74
- v{updateAvailable.version} available — press <span fg={colors.headerText}>u</span> to update
74
+ v{updateAvailable.version} available — starting download...
75
75
  </text>
76
76
  );
77
77
  }
@@ -1,4 +1,4 @@
1
- import { floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
1
+ import { colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
2
2
 
3
3
  export const PANE_HEADER_HEIGHT = 1;
4
4
  export const PANE_HEADER_GRIP = ":: ";
@@ -30,13 +30,37 @@ export function PaneHeader({
30
30
  const backgroundColor = floating ? floatingPaneTitleBg(focused) : paneTitleBg(focused);
31
31
  const actionText = showActions ? PANE_HEADER_ACTION : " ";
32
32
  const closeText = floating ? PANE_HEADER_CLOSE : "";
33
+ const bc = colors.borderFocused;
34
+ const textColor = paneTitleText(focused, floating);
35
+
36
+ if (focused) {
37
+ // Build: ┌─:: Title ─────────── ... x─┐
38
+ // Reserve 2 for corners, 1 for ─ after ┌, 1 for ─ before ┐
39
+ const innerWidth = Math.max(0, width - 4);
40
+ const contentWidth = PANE_HEADER_GRIP.length + closeText.length + actionText.length;
41
+ const titleWidth = Math.max(0, innerWidth - contentWidth);
42
+ const clippedTitle = truncateTitle(title, titleWidth);
43
+ const fillLen = Math.max(0, innerWidth - PANE_HEADER_GRIP.length - clippedTitle.length - actionText.length - closeText.length);
44
+ const fill = "─".repeat(fillLen);
45
+
46
+ return (
47
+ <box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
48
+ <text fg={bc} selectable={false}>{"┌─"}</text>
49
+ <text fg={textColor} selectable={false}>{`${PANE_HEADER_GRIP}${clippedTitle}`}</text>
50
+ <text fg={bc} selectable={false}>{fill}</text>
51
+ <text fg={textColor} selectable={false}>{`${actionText}${closeText}`}</text>
52
+ <text fg={bc} selectable={false}>{"─┐"}</text>
53
+ </box>
54
+ );
55
+ }
56
+
33
57
  const titleWidth = Math.max(0, width - PANE_HEADER_GRIP.length - actionText.length - closeText.length);
34
58
  const clippedTitle = truncateTitle(title, titleWidth);
35
59
  const padding = " ".repeat(Math.max(0, titleWidth - clippedTitle.length));
36
60
 
37
61
  return (
38
62
  <box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
39
- <text fg={paneTitleText(focused, floating)} selectable={false}>
63
+ <text fg={textColor} selectable={false}>
40
64
  {`${PANE_HEADER_GRIP}${clippedTitle}${padding}${actionText}${closeText}`}
41
65
  </text>
42
66
  </box>
@@ -0,0 +1,6 @@
1
+ const RESERVED_PANE_CHROME_ROWS = 2;
2
+
3
+ export function getPaneBodyHeight(height: number): number {
4
+ // Reserve the header row plus the bottom border/resize row.
5
+ return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
6
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { paneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
+ import { getPaneBodyHeight } from "./pane-sizing";
4
5
 
5
6
  interface PaneWrapperProps {
6
7
  title?: string;
@@ -26,6 +27,9 @@ export function PaneWrapper({
26
27
  children,
27
28
  }: PaneWrapperProps) {
28
29
  const bg = paneBg(focused);
30
+ const bodyHeight = typeof height === "number"
31
+ ? title ? getPaneBodyHeight(height) : height
32
+ : undefined;
29
33
 
30
34
  return (
31
35
  <box
@@ -41,7 +45,7 @@ export function PaneWrapper({
41
45
  {title && (
42
46
  <PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
43
47
  )}
44
- <box flexGrow={1} overflow="hidden">
48
+ <box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
45
49
  {children}
46
50
  </box>
47
51
  </box>
@@ -1,12 +1,14 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { testRender } from "@opentui/react/test-utils";
3
3
  import { DialogProvider } from "@opentui-ui/dialog/react";
4
+ import type { ReactNode } from "react";
4
5
  import { act, useReducer } from "react";
5
6
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
6
7
  import { cloneLayout, createDefaultConfig } from "../../types/config";
7
8
  import type { PluginRegistry } from "../../plugins/registry";
8
9
  import { setSharedRegistryForTests } from "../../plugins/registry";
9
10
  import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
11
+ import type { PaneProps } from "../../types/plugin";
10
12
  import { StatusBar } from "./status-bar";
11
13
  import { Header } from "./header";
12
14
  import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
@@ -26,7 +28,9 @@ afterEach(() => {
26
28
  setSharedRegistryForTests(undefined);
27
29
  });
28
30
 
29
- function createShellPluginRegistry(): PluginRegistry {
31
+ function createShellPluginRegistry(options?: {
32
+ tickerDetailComponent?: (props: PaneProps) => ReactNode;
33
+ }): PluginRegistry {
30
34
  return {
31
35
  panes: new Map([
32
36
  ["portfolio-list", {
@@ -38,7 +42,7 @@ function createShellPluginRegistry(): PluginRegistry {
38
42
  ["ticker-detail", {
39
43
  id: "ticker-detail",
40
44
  name: "Ticker Detail",
41
- component: () => <text>Ticker Detail Body</text>,
45
+ component: options?.tickerDetailComponent ?? (() => <text>Ticker Detail Body</text>),
42
46
  defaultPosition: "right",
43
47
  defaultMode: "floating",
44
48
  }],
@@ -160,15 +164,18 @@ function createHeaderDataProvider(): DataProvider {
160
164
 
161
165
  function HeaderHarness({
162
166
  updateAvailable = null,
167
+ updateProgress = null,
163
168
  updateCheckInProgress = false,
164
169
  updateNotice = null,
165
170
  }: {
166
171
  updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
172
+ updateProgress?: ReturnType<typeof createInitialState>["updateProgress"];
167
173
  updateCheckInProgress?: boolean;
168
174
  updateNotice?: string | null;
169
175
  }) {
170
176
  const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
171
177
  initialState.updateAvailable = updateAvailable;
178
+ initialState.updateProgress = updateProgress;
172
179
  initialState.updateCheckInProgress = updateCheckInProgress;
173
180
  initialState.updateNotice = updateNotice;
174
181
  const [state, dispatch] = useReducer(appReducer, initialState);
@@ -258,7 +265,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
258
265
  }
259
266
 
260
267
  describe("Header", () => {
261
- test("shows the self-update shortcut for standalone binaries", async () => {
268
+ test("shows automatic self-update status for standalone binaries", async () => {
262
269
  testSetup = await testRender(
263
270
  <HeaderHarness updateAvailable={{
264
271
  version: "0.3.0",
@@ -274,7 +281,8 @@ describe("Header", () => {
274
281
  const frame = testSetup.captureCharFrame();
275
282
 
276
283
  expect(frame).toContain("v0.3.0 available");
277
- expect(frame).toContain("press u to update");
284
+ expect(frame).toContain("starting download");
285
+ expect(frame).not.toContain("press u to update");
278
286
  });
279
287
 
280
288
  test("shows the manual npm command when self-update is disabled", async () => {
@@ -297,7 +305,26 @@ describe("Header", () => {
297
305
  expect(frame).not.toContain("press u to update");
298
306
  });
299
307
 
300
- test("shows update check progress and notices", async () => {
308
+ test("shows update download progress and notices", async () => {
309
+ testSetup = await testRender(
310
+ <HeaderHarness
311
+ updateAvailable={{
312
+ version: "0.3.0",
313
+ tagName: "v0.3.0",
314
+ downloadUrl: "https://example.com/gloomberb",
315
+ publishedAt: "2026-04-01T00:00:00.000Z",
316
+ updateAction: { kind: "self" },
317
+ }}
318
+ updateProgress={{ phase: "downloading", percent: 42 }}
319
+ />,
320
+ { width: 120, height: 2 },
321
+ );
322
+
323
+ await testSetup.renderOnce();
324
+ expect(testSetup.captureCharFrame()).toContain("Downloading v0.3.0: 42%");
325
+
326
+ testSetup.renderer.destroy();
327
+
301
328
  testSetup = await testRender(
302
329
  <HeaderHarness updateCheckInProgress />,
303
330
  { width: 120, height: 2 },
@@ -648,4 +675,49 @@ describe("Shell", () => {
648
675
  expect(updateLayout?.layout.floating).toEqual([]);
649
676
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
650
677
  });
678
+
679
+ test("keeps the last floating pane body row visible above the border", async () => {
680
+ const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
681
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
682
+ if (!detailPane) throw new Error("missing detail pane");
683
+
684
+ const floatingOnlyLayout = {
685
+ dockRoot: null,
686
+ instances: [{ ...detailPane }],
687
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
688
+ };
689
+ const state = {
690
+ ...createInitialState({
691
+ ...config,
692
+ layout: cloneLayout(floatingOnlyLayout),
693
+ layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
694
+ }),
695
+ focusedPaneId: "ticker-detail:main",
696
+ };
697
+
698
+ testSetup = await testRender(
699
+ <AppContext value={{ state, dispatch: () => {} }}>
700
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
701
+ <Shell pluginRegistry={createShellPluginRegistry({
702
+ tickerDetailComponent: ({ width, height }) => (
703
+ <box flexDirection="column" width={width} height={height}>
704
+ <box flexGrow={1} />
705
+ <box paddingLeft={1}>
706
+ <text>Footer Probe</text>
707
+ </box>
708
+ </box>
709
+ ),
710
+ })} />
711
+ </DialogProvider>
712
+ </AppContext>,
713
+ { width: 40, height: 12 },
714
+ );
715
+
716
+ await act(async () => {
717
+ await testSetup!.renderOnce();
718
+ await testSetup!.renderOnce();
719
+ });
720
+
721
+ expect(testSetup.captureCharFrame()).toContain("Footer Probe");
722
+ });
651
723
  });
@@ -5,10 +5,7 @@ import { saveConfig } from "../../data/config-store";
5
5
  import {
6
6
  MIN_FLOAT_HEIGHT,
7
7
  MIN_FLOAT_WIDTH,
8
- addPaneFloating,
9
- addPaneToLayout,
10
8
  applyDrop,
11
- bringToFront,
12
9
  findDockLeaf,
13
10
  floatAtRect,
14
11
  floatPane,
@@ -29,7 +26,7 @@ import {
29
26
  type ResolvedPane,
30
27
  } from "../../plugins/pane-manager";
31
28
  import type { PluginRegistry } from "../../plugins/registry";
32
- import { removePaneInstances, createPaneInstance, type LayoutConfig } from "../../types/config";
29
+ import { removePaneInstances, type LayoutConfig } from "../../types/config";
33
30
  import {
34
31
  PaneInstanceProvider,
35
32
  resolveCollectionForPane,
@@ -41,6 +38,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
41
38
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
42
39
  import { FloatingPaneWrapper } from "./floating-pane";
43
40
  import { PaneWrapper } from "./pane";
41
+ import { getPaneBodyHeight } from "./pane-sizing";
44
42
 
45
43
  interface ShellProps {
46
44
  pluginRegistry: PluginRegistry;
@@ -435,12 +433,13 @@ function resolveSnapGuide(x: number, y: number, guides: SnapGuide[]): SnapGuide
435
433
 
436
434
  function resolveHeaderHitAreas(
437
435
  width: number,
438
- options: { floating: boolean },
436
+ options: { floating: boolean; focused: boolean },
439
437
  ): {
440
438
  actionStart: number | null;
441
439
  closeStart: number | null;
442
440
  } {
443
- let rightEdge = width;
441
+ // When focused, the header renders ┌─...─┐ adding 2 chars on each side
442
+ let rightEdge = options.focused ? width - 2 : width;
444
443
  let closeStart: number | null = null;
445
444
  let actionStart: number | null = null;
446
445
 
@@ -463,7 +462,6 @@ function menuForPane(
463
462
  persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
464
463
  focusPane: (paneId: string) => void,
465
464
  openPaneSettings: (paneId: string) => void,
466
- openLayoutMenu: () => void,
467
465
  ) {
468
466
  const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
469
467
  if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
@@ -483,11 +481,6 @@ function menuForPane(
483
481
  focusPane(pane.instance.instanceId);
484
482
  },
485
483
  });
486
- baseActions.push({
487
- id: "front",
488
- label: "Bring To Front",
489
- action: () => persistLayout(bringToFront(layout, pane.instance.instanceId), { pushHistory: false }),
490
- });
491
484
  } else {
492
485
  baseActions.push({
493
486
  id: "float",
@@ -499,37 +492,6 @@ function menuForPane(
499
492
  });
500
493
  }
501
494
 
502
- baseActions.push({
503
- id: "duplicate",
504
- label: "Duplicate",
505
- action: () => {
506
- const duplicate = createPaneInstance(pane.instance.paneId, {
507
- title: pane.instance.title,
508
- binding: pane.instance.binding,
509
- params: pane.instance.params,
510
- settings: pane.instance.settings,
511
- });
512
- const nextLayout = pane.floating
513
- ? floatAtRect(addPaneFloating(layout, duplicate, width, contentHeight, pane.def), duplicate.instanceId, {
514
- x: Math.max(0, pane.floating.x + 2),
515
- y: Math.max(0, pane.floating.y + 1),
516
- width: pane.floating.width,
517
- height: pane.floating.height,
518
- })
519
- : addPaneToLayout(layout, duplicate, { relativeTo: pane.instance.instanceId, position: "right" });
520
- persistLayout(nextLayout);
521
- focusPane(duplicate.instanceId);
522
- },
523
- });
524
- baseActions.push({ id: "swap", label: "Swap With...", action: openLayoutMenu });
525
- baseActions.push({
526
- id: "close",
527
- label: "Close",
528
- action: () => {
529
- persistLayout(removePane(layout, pane.instance.instanceId));
530
- },
531
- });
532
-
533
495
  return baseActions;
534
496
  }
535
497
 
@@ -739,10 +701,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
739
701
  persistLayout,
740
702
  focusPane,
741
703
  openPaneSettings,
742
- openLayoutMenu,
743
704
  ),
744
705
  });
745
- }, [contentHeight, focusPane, openLayoutMenu, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
706
+ }, [contentHeight, focusPane, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
746
707
 
747
708
  const handleFloatingClose = useCallback((paneId: string) => {
748
709
  persistLayout(removePane(visibleLayout, paneId));
@@ -768,13 +729,12 @@ export function Shell({ pluginRegistry }: ShellProps) {
768
729
  if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
769
730
  const relativeX = event.x - rect.x;
770
731
  const relativeY = shellY - rect.y;
732
+ const isFocused = state.focusedPaneId === pane.instance.instanceId;
771
733
  const headerAreas = resolveHeaderHitAreas(rect.width, {
772
734
  floating: true,
735
+ focused: isFocused,
773
736
  });
774
737
  focusPane(pane.instance.instanceId);
775
- if ((pane.floating?.zIndex ?? 50) < 999) {
776
- persistLayout(bringToFront(visibleLayout, pane.instance.instanceId), { pushHistory: false });
777
- }
778
738
  if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
779
739
  handleFloatingClose(pane.instance.instanceId);
780
740
  event.stopPropagation();
@@ -850,8 +810,10 @@ export function Shell({ pluginRegistry }: ShellProps) {
850
810
  if (!pane) continue;
851
811
  const relativeX = event.x - leaf.rect.x;
852
812
  const relativeY = shellY - leaf.rect.y;
813
+ const isFocused = state.focusedPaneId === leaf.instanceId;
853
814
  const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
854
815
  floating: false,
816
+ focused: isFocused,
855
817
  });
856
818
  focusPane(leaf.instanceId);
857
819
  if (relativeY === 0
@@ -1021,8 +983,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
1021
983
  {dockLeafLayouts.map((leaf) => {
1022
984
  const pane = paneMap.get(leaf.instanceId);
1023
985
  if (!pane) return null;
1024
- const focused = state.focusedPaneId === leaf.instanceId && !overlayOpen;
986
+ const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
1025
987
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
988
+ const bodyHeight = getPaneBodyHeight(leaf.rect.height);
1026
989
  return (
1027
990
  <box
1028
991
  key={`dock:${leaf.instanceId}`}
@@ -1046,7 +1009,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1046
1009
  paneType={pane.instance.paneId}
1047
1010
  focused={focused}
1048
1011
  width={leaf.rect.width}
1049
- height={leaf.rect.height - 1}
1012
+ height={bodyHeight}
1050
1013
  />
1051
1014
  </PaneInstanceProvider>
1052
1015
  </PaneWrapper>
@@ -1056,8 +1019,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
1056
1019
 
1057
1020
  {floatingPanes.map((pane) => {
1058
1021
  const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1059
- const focused = state.focusedPaneId === pane.instance.instanceId && !overlayOpen;
1022
+ const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1060
1023
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1024
+ const bodyHeight = getPaneBodyHeight(preview.height);
1061
1025
  return (
1062
1026
  <FloatingPaneWrapper
1063
1027
  key={`float:${pane.instance.instanceId}`}
@@ -1077,7 +1041,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1077
1041
  paneType={pane.instance.paneId}
1078
1042
  focused={focused}
1079
1043
  width={preview.width}
1080
- height={preview.height - 1}
1044
+ height={bodyHeight}
1081
1045
  close={() => handleFloatingClose(pane.instance.instanceId)}
1082
1046
  />
1083
1047
  </PaneInstanceProvider>
@@ -1108,6 +1072,57 @@ export function Shell({ pluginRegistry }: ShellProps) {
1108
1072
  );
1109
1073
  })}
1110
1074
 
1075
+ {/* Focus border overlay — rendered on top of the focused pane */}
1076
+ {(() => {
1077
+ if (!state.focusedPaneId) return null;
1078
+ // Hide border when command bar or dialog is open, but keep it when just the pane menu is open
1079
+ if (overlayOpen && !menuState) return null;
1080
+ let rect: { x: number; y: number; width: number; height: number } | null = null;
1081
+ let z = 3;
1082
+ // Check floating panes first (they render on top of docked panes)
1083
+ const floatingPane = floatingPanes.find((p) => p.instance.instanceId === state.focusedPaneId);
1084
+ if (floatingPane) {
1085
+ rect = dragFloatingRect?.paneId === floatingPane.instance.instanceId
1086
+ ? dragFloatingRect.rect
1087
+ : floatingPane.floating!;
1088
+ z = (floatingPane.floating?.zIndex ?? 50) + 1;
1089
+ } else {
1090
+ // Check docked panes
1091
+ const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === state.focusedPaneId);
1092
+ if (dockedLeaf) {
1093
+ rect = dockedLeaf.rect;
1094
+ }
1095
+ }
1096
+ if (!rect || rect.height < 2) return null;
1097
+ const bc = colors.borderFocused;
1098
+ const isFloating = !!floatingPane;
1099
+ const bodyTop = rect.y + 1; // below header (header renders its own border edges)
1100
+ const bodyH = rect.height - 2; // rows between header and bottom edge
1101
+ return (
1102
+ <>
1103
+ {/* Left edge — body only */}
1104
+ {bodyH > 0 && (
1105
+ <box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1106
+ <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1107
+ </box>
1108
+ )}
1109
+ {/* Right edge — body only */}
1110
+ {bodyH > 0 && (
1111
+ <box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1112
+ <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1113
+ </box>
1114
+ )}
1115
+ {/* Bottom edge — for floating, leave last 2 chars for resize handle (rendered by FloatingPaneWrapper) */}
1116
+ <box key="focus-b" position="absolute" left={rect.x} top={rect.y + rect.height - 1} width={isFloating ? Math.max(0, rect.width - 2) : rect.width} height={1} zIndex={z}>
1117
+ <text fg={bc} selectable={false}>{isFloating
1118
+ ? `└${"─".repeat(Math.max(0, rect.width - 4))}─`
1119
+ : `└${"─".repeat(Math.max(0, rect.width - 2))}┘`
1120
+ }</text>
1121
+ </box>
1122
+ </>
1123
+ );
1124
+ })()}
1125
+
1111
1126
  {activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
1112
1127
  const active = dockPreview?.kind === "dock"
1113
1128
  && dockPreview.target.kind === "leaf"
@@ -170,4 +170,70 @@ describe("StatusBar", () => {
170
170
  globalThis.clearTimeout = originalClearTimeout;
171
171
  }
172
172
  });
173
+
174
+ test("separates listing venue, session, and source labels", async () => {
175
+ const config = createDefaultConfig("/tmp/gloomberb-test");
176
+ config.layout.instances = config.layout.instances.map((instance) => (
177
+ instance.instanceId === "ticker-detail:main"
178
+ ? { ...instance, binding: { kind: "fixed" as const, symbol: "AMD" } }
179
+ : instance
180
+ ));
181
+ const state = createInitialState(config);
182
+ state.statusBarVisible = true;
183
+ state.focusedPaneId = "ticker-detail:main";
184
+ state.tickers = new Map([["AMD", {
185
+ metadata: {
186
+ ticker: "AMD",
187
+ exchange: "NASDAQ",
188
+ currency: "USD",
189
+ name: "Advanced Micro Devices",
190
+ portfolios: [],
191
+ watchlists: [],
192
+ positions: [],
193
+ custom: {},
194
+ tags: [],
195
+ },
196
+ }]]);
197
+ state.financials = new Map([["AMD", {
198
+ annualStatements: [],
199
+ quarterlyStatements: [],
200
+ priceHistory: [],
201
+ quote: {
202
+ symbol: "AMD",
203
+ providerId: "ibkr",
204
+ dataSource: "live",
205
+ price: 100,
206
+ currency: "USD",
207
+ change: 1,
208
+ changePercent: 1,
209
+ lastUpdated: Date.now(),
210
+ listingExchangeName: "NASDAQ",
211
+ routingExchangeName: "SMART",
212
+ marketState: "PRE",
213
+ sessionConfidence: "derived",
214
+ preMarketPrice: 101,
215
+ preMarketChange: 2,
216
+ preMarketChangePercent: 2,
217
+ provenance: {
218
+ price: { providerId: "ibkr", dataSource: "live" },
219
+ session: { providerId: "yahoo", dataSource: "yahoo" },
220
+ },
221
+ },
222
+ }]]);
223
+
224
+ testSetup = await testRender(
225
+ <AppContext value={{ state, dispatch: () => {} }}>
226
+ <StatusBar />
227
+ </AppContext>,
228
+ { width: 120, height: 1 },
229
+ );
230
+
231
+ await testSetup.renderOnce();
232
+
233
+ const frame = testSetup.captureCharFrame();
234
+ expect(frame).toContain("NASDAQ PRE-MKT");
235
+ expect(frame).toContain("px IBKR live");
236
+ expect(frame).toContain("ses Yahoo");
237
+ expect(frame).not.toContain("SMART OPEN");
238
+ });
173
239
  });
@@ -1,7 +1,13 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { colors, hoverBg } from "../../theme/colors";
3
3
  import { useAppState, useFocusedTicker } from "../../state/app-context";
4
- import { marketStateLabel, marketStateColor, exchangeShortName, getExtendedHoursInfo } from "../../utils/market-status";
4
+ import {
5
+ marketStateLabel,
6
+ marketStateColor,
7
+ exchangeShortName,
8
+ getExtendedHoursInfo,
9
+ quoteSourceLabel,
10
+ } from "../../utils/market-status";
5
11
  import { getSharedRegistry } from "../../plugins/registry";
6
12
  import { gridlockAllPanes } from "../../plugins/pane-manager";
7
13
 
@@ -23,7 +29,12 @@ export function StatusBar() {
23
29
 
24
30
  const q = focusedFinancials?.quote;
25
31
  const mktState = q?.marketState;
26
- const exchName = q ? exchangeShortName(q.exchangeName, q.fullExchangeName) : "";
32
+ const exchName = q
33
+ ? exchangeShortName(q.listingExchangeName ?? q.exchangeName, q.listingExchangeFullName ?? q.fullExchangeName)
34
+ : "";
35
+ const sessionLabel = mktState ? marketStateLabel(mktState) : "?";
36
+ const priceSourceLabel = q?.provenance?.price ? quoteSourceLabel(q.provenance.price, "price") : "";
37
+ const sessionSourceLabel = q?.provenance?.session ? quoteSourceLabel(q.provenance.session, "session") : "";
27
38
 
28
39
  // Extended hours info for selected ticker
29
40
  const extInfo = getExtendedHoursInfo(focusedFinancials?.quote);
@@ -124,13 +135,23 @@ export function StatusBar() {
124
135
  <text fg={extColor}>{symbol} {extText}</text>
125
136
  </box>
126
137
  )}
127
- {mktState && (
138
+ {(exchName || mktState || q?.provenance?.session) && (
128
139
  <box paddingRight={1}>
129
- <text fg={marketStateColor(mktState)}>
130
- {exchName ? `${exchName} ` : ""}{marketStateLabel(mktState)}
140
+ <text fg={mktState ? marketStateColor(mktState) : colors.textDim}>
141
+ {exchName ? `${exchName} ` : ""}{sessionLabel}
131
142
  </text>
132
143
  </box>
133
144
  )}
145
+ {priceSourceLabel && (
146
+ <box paddingRight={1}>
147
+ <text fg={colors.textDim}>px {priceSourceLabel}</text>
148
+ </box>
149
+ )}
150
+ {sessionSourceLabel && sessionSourceLabel !== priceSourceLabel && (
151
+ <box paddingRight={1}>
152
+ <text fg={colors.textDim}>ses {sessionSourceLabel}</text>
153
+ </box>
154
+ )}
134
155
  {/* Plugin status widgets */}
135
156
  {registry && <registry.Slot name="status:widget" />}
136
157
  </box>