gloomberb 0.3.3 → 0.4.0
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.
- package/README.md +27 -16
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-renderer.ts +7 -2
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/chart.test.ts +26 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +484 -112
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +75 -1
- package/src/plugins/ibkr/gateway-service.ts +179 -9
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/chart.test.tsx +139 -0
- package/src/plugins/prediction-markets/chart.tsx +65 -436
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +17 -5
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
- package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
- package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
- package/src/plugins/prediction-markets/detail.test.tsx +4 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +160 -58
- package/src/plugins/prediction-markets/pane.tsx +74 -183
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +308 -3
- package/src/sources/provider-router.ts +247 -17
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/price-history.test.ts +25 -0
- package/src/utils/price-history.ts +29 -5
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -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={
|
|
63
|
+
<text fg={textColor} selectable={false}>
|
|
40
64
|
{`${PANE_HEADER_GRIP}${clippedTitle}${padding}${actionText}${closeText}`}
|
|
41
65
|
</text>
|
|
42
66
|
</box>
|
|
@@ -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,
|
|
29
|
+
import { removePaneInstances, type LayoutConfig } from "../../types/config";
|
|
33
30
|
import {
|
|
34
31
|
PaneInstanceProvider,
|
|
35
32
|
resolveCollectionForPane,
|
|
@@ -435,12 +432,13 @@ function resolveSnapGuide(x: number, y: number, guides: SnapGuide[]): SnapGuide
|
|
|
435
432
|
|
|
436
433
|
function resolveHeaderHitAreas(
|
|
437
434
|
width: number,
|
|
438
|
-
options: { floating: boolean },
|
|
435
|
+
options: { floating: boolean; focused: boolean },
|
|
439
436
|
): {
|
|
440
437
|
actionStart: number | null;
|
|
441
438
|
closeStart: number | null;
|
|
442
439
|
} {
|
|
443
|
-
|
|
440
|
+
// When focused, the header renders ┌─...─┐ adding 2 chars on each side
|
|
441
|
+
let rightEdge = options.focused ? width - 2 : width;
|
|
444
442
|
let closeStart: number | null = null;
|
|
445
443
|
let actionStart: number | null = null;
|
|
446
444
|
|
|
@@ -463,7 +461,6 @@ function menuForPane(
|
|
|
463
461
|
persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
|
|
464
462
|
focusPane: (paneId: string) => void,
|
|
465
463
|
openPaneSettings: (paneId: string) => void,
|
|
466
|
-
openLayoutMenu: () => void,
|
|
467
464
|
) {
|
|
468
465
|
const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
|
|
469
466
|
if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
|
|
@@ -483,11 +480,6 @@ function menuForPane(
|
|
|
483
480
|
focusPane(pane.instance.instanceId);
|
|
484
481
|
},
|
|
485
482
|
});
|
|
486
|
-
baseActions.push({
|
|
487
|
-
id: "front",
|
|
488
|
-
label: "Bring To Front",
|
|
489
|
-
action: () => persistLayout(bringToFront(layout, pane.instance.instanceId), { pushHistory: false }),
|
|
490
|
-
});
|
|
491
483
|
} else {
|
|
492
484
|
baseActions.push({
|
|
493
485
|
id: "float",
|
|
@@ -499,37 +491,6 @@ function menuForPane(
|
|
|
499
491
|
});
|
|
500
492
|
}
|
|
501
493
|
|
|
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
494
|
return baseActions;
|
|
534
495
|
}
|
|
535
496
|
|
|
@@ -739,10 +700,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
739
700
|
persistLayout,
|
|
740
701
|
focusPane,
|
|
741
702
|
openPaneSettings,
|
|
742
|
-
openLayoutMenu,
|
|
743
703
|
),
|
|
744
704
|
});
|
|
745
|
-
}, [contentHeight, focusPane,
|
|
705
|
+
}, [contentHeight, focusPane, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
|
|
746
706
|
|
|
747
707
|
const handleFloatingClose = useCallback((paneId: string) => {
|
|
748
708
|
persistLayout(removePane(visibleLayout, paneId));
|
|
@@ -768,13 +728,12 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
768
728
|
if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
|
|
769
729
|
const relativeX = event.x - rect.x;
|
|
770
730
|
const relativeY = shellY - rect.y;
|
|
731
|
+
const isFocused = state.focusedPaneId === pane.instance.instanceId;
|
|
771
732
|
const headerAreas = resolveHeaderHitAreas(rect.width, {
|
|
772
733
|
floating: true,
|
|
734
|
+
focused: isFocused,
|
|
773
735
|
});
|
|
774
736
|
focusPane(pane.instance.instanceId);
|
|
775
|
-
if ((pane.floating?.zIndex ?? 50) < 999) {
|
|
776
|
-
persistLayout(bringToFront(visibleLayout, pane.instance.instanceId), { pushHistory: false });
|
|
777
|
-
}
|
|
778
737
|
if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
|
|
779
738
|
handleFloatingClose(pane.instance.instanceId);
|
|
780
739
|
event.stopPropagation();
|
|
@@ -850,8 +809,10 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
850
809
|
if (!pane) continue;
|
|
851
810
|
const relativeX = event.x - leaf.rect.x;
|
|
852
811
|
const relativeY = shellY - leaf.rect.y;
|
|
812
|
+
const isFocused = state.focusedPaneId === leaf.instanceId;
|
|
853
813
|
const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
|
|
854
814
|
floating: false,
|
|
815
|
+
focused: isFocused,
|
|
855
816
|
});
|
|
856
817
|
focusPane(leaf.instanceId);
|
|
857
818
|
if (relativeY === 0
|
|
@@ -1021,7 +982,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1021
982
|
{dockLeafLayouts.map((leaf) => {
|
|
1022
983
|
const pane = paneMap.get(leaf.instanceId);
|
|
1023
984
|
if (!pane) return null;
|
|
1024
|
-
const focused = state.focusedPaneId === leaf.instanceId && !overlayOpen;
|
|
985
|
+
const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
|
|
1025
986
|
const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
|
|
1026
987
|
return (
|
|
1027
988
|
<box
|
|
@@ -1056,7 +1017,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1056
1017
|
|
|
1057
1018
|
{floatingPanes.map((pane) => {
|
|
1058
1019
|
const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
|
|
1059
|
-
const focused = state.focusedPaneId === pane.instance.instanceId && !overlayOpen;
|
|
1020
|
+
const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
|
|
1060
1021
|
const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
|
|
1061
1022
|
return (
|
|
1062
1023
|
<FloatingPaneWrapper
|
|
@@ -1108,6 +1069,57 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1108
1069
|
);
|
|
1109
1070
|
})}
|
|
1110
1071
|
|
|
1072
|
+
{/* Focus border overlay — rendered on top of the focused pane */}
|
|
1073
|
+
{(() => {
|
|
1074
|
+
if (!state.focusedPaneId) return null;
|
|
1075
|
+
// Hide border when command bar or dialog is open, but keep it when just the pane menu is open
|
|
1076
|
+
if (overlayOpen && !menuState) return null;
|
|
1077
|
+
let rect: { x: number; y: number; width: number; height: number } | null = null;
|
|
1078
|
+
let z = 3;
|
|
1079
|
+
// Check floating panes first (they render on top of docked panes)
|
|
1080
|
+
const floatingPane = floatingPanes.find((p) => p.instance.instanceId === state.focusedPaneId);
|
|
1081
|
+
if (floatingPane) {
|
|
1082
|
+
rect = dragFloatingRect?.paneId === floatingPane.instance.instanceId
|
|
1083
|
+
? dragFloatingRect.rect
|
|
1084
|
+
: floatingPane.floating!;
|
|
1085
|
+
z = (floatingPane.floating?.zIndex ?? 50) + 1;
|
|
1086
|
+
} else {
|
|
1087
|
+
// Check docked panes
|
|
1088
|
+
const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === state.focusedPaneId);
|
|
1089
|
+
if (dockedLeaf) {
|
|
1090
|
+
rect = dockedLeaf.rect;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!rect || rect.height < 2) return null;
|
|
1094
|
+
const bc = colors.borderFocused;
|
|
1095
|
+
const isFloating = !!floatingPane;
|
|
1096
|
+
const bodyTop = rect.y + 1; // below header (header renders its own border edges)
|
|
1097
|
+
const bodyH = rect.height - 2; // rows between header and bottom edge
|
|
1098
|
+
return (
|
|
1099
|
+
<>
|
|
1100
|
+
{/* Left edge — body only */}
|
|
1101
|
+
{bodyH > 0 && (
|
|
1102
|
+
<box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
|
|
1103
|
+
<text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
|
|
1104
|
+
</box>
|
|
1105
|
+
)}
|
|
1106
|
+
{/* Right edge — body only */}
|
|
1107
|
+
{bodyH > 0 && (
|
|
1108
|
+
<box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
|
|
1109
|
+
<text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
|
|
1110
|
+
</box>
|
|
1111
|
+
)}
|
|
1112
|
+
{/* Bottom edge — for floating, leave last 2 chars for resize handle (rendered by FloatingPaneWrapper) */}
|
|
1113
|
+
<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}>
|
|
1114
|
+
<text fg={bc} selectable={false}>{isFloating
|
|
1115
|
+
? `└${"─".repeat(Math.max(0, rect.width - 4))}─`
|
|
1116
|
+
: `└${"─".repeat(Math.max(0, rect.width - 2))}┘`
|
|
1117
|
+
}</text>
|
|
1118
|
+
</box>
|
|
1119
|
+
</>
|
|
1120
|
+
);
|
|
1121
|
+
})()}
|
|
1122
|
+
|
|
1111
1123
|
{activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
|
|
1112
1124
|
const active = dockPreview?.kind === "dock"
|
|
1113
1125
|
&& 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 {
|
|
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
|
|
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} ` : ""}{
|
|
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>
|
|
@@ -65,6 +65,20 @@ async function emitKeypress(
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
async function waitForFrameToContain(text: string, attempts = 8): Promise<string> {
|
|
69
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
70
|
+
const frame = testSetup!.captureCharFrame();
|
|
71
|
+
if (frame.includes(text)) {
|
|
72
|
+
return frame;
|
|
73
|
+
}
|
|
74
|
+
await act(async () => {
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
await testSetup!.renderOnce();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Timed out waiting for "${text}".`);
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
afterEach(async () => {
|
|
69
83
|
if (testSetup) {
|
|
70
84
|
await act(async () => {
|
|
@@ -79,6 +93,57 @@ afterEach(async () => {
|
|
|
79
93
|
});
|
|
80
94
|
|
|
81
95
|
describe("OnboardingWizard", () => {
|
|
96
|
+
test("uses backspace to go back when not editing and keeps backspace for text deletion while editing", async () => {
|
|
97
|
+
const pluginRegistry = {
|
|
98
|
+
allPlugins: new Map(),
|
|
99
|
+
brokers: new Map(),
|
|
100
|
+
paneTemplates: new Map(),
|
|
101
|
+
getPaneTemplatePluginId: () => undefined,
|
|
102
|
+
tickerRepository: createTickerRepository(),
|
|
103
|
+
persistence: { resources: undefined },
|
|
104
|
+
} as unknown as PluginRegistry;
|
|
105
|
+
|
|
106
|
+
testSetup = await testRender(
|
|
107
|
+
<OnboardingWizard
|
|
108
|
+
config={createDefaultConfig("/tmp/gloomberb-onboarding-backspace")}
|
|
109
|
+
pluginRegistry={pluginRegistry}
|
|
110
|
+
onComplete={() => {}}
|
|
111
|
+
/>,
|
|
112
|
+
{ width: 90, height: 28 },
|
|
113
|
+
);
|
|
114
|
+
await testSetup.renderOnce();
|
|
115
|
+
|
|
116
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
117
|
+
let frame = await waitForFrameToContain("Theme");
|
|
118
|
+
expect(frame).toContain("Theme");
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
testSetup!.mockInput.pressBackspace();
|
|
122
|
+
await testSetup!.renderOnce();
|
|
123
|
+
await testSetup!.renderOnce();
|
|
124
|
+
});
|
|
125
|
+
frame = await waitForFrameToContain("Let's set things up (~30s).");
|
|
126
|
+
expect(frame).toContain("Let's set things up (~30s).");
|
|
127
|
+
|
|
128
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
129
|
+
frame = await waitForFrameToContain("Theme");
|
|
130
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
131
|
+
frame = await waitForFrameToContain("Create Manual Portfolio");
|
|
132
|
+
expect(frame).toContain("Create Manual Portfolio");
|
|
133
|
+
|
|
134
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
135
|
+
frame = await waitForFrameToContain("Main Portfolio");
|
|
136
|
+
expect(frame).toContain("Main Portfolio");
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
testSetup!.mockInput.pressBackspace();
|
|
140
|
+
await testSetup!.renderOnce();
|
|
141
|
+
});
|
|
142
|
+
frame = testSetup.captureCharFrame();
|
|
143
|
+
expect(frame).toContain("Main Portfoli");
|
|
144
|
+
expect(frame).not.toContain("Create Manual Portfolio");
|
|
145
|
+
});
|
|
146
|
+
|
|
82
147
|
test("waits for broker positions to import before completing onboarding", async () => {
|
|
83
148
|
tempDataDir = await mkdtemp(join(tmpdir(), "gloomberb-onboarding-"));
|
|
84
149
|
const importDeferred = createDeferred<any[]>();
|
|
@@ -10,6 +10,7 @@ import type { PluginRegistry } from "../../plugins/registry";
|
|
|
10
10
|
import { resolveBrokerConfigFields, type BrokerAdapter, type BrokerConfigField } from "../../types/broker";
|
|
11
11
|
import { buildIbkrConfigFromValues } from "../../plugins/ibkr/config";
|
|
12
12
|
import { createBrokerInstanceId } from "../../utils/broker-instances";
|
|
13
|
+
import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation";
|
|
13
14
|
import { syncBrokerInstance } from "../../brokers/sync-broker-instance";
|
|
14
15
|
import { debugLog } from "../../utils/debug-log";
|
|
15
16
|
import { ToggleList, type ToggleListItem } from "../toggle-list";
|
|
@@ -415,7 +416,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
415
416
|
}
|
|
416
417
|
}
|
|
417
418
|
}
|
|
418
|
-
} else if (event
|
|
419
|
+
} else if (isPlainEscape(event)) {
|
|
419
420
|
setEditingField(false);
|
|
420
421
|
if (portfolioSub === "broker-fields") {
|
|
421
422
|
setPortfolioSub("broker-setup");
|
|
@@ -506,7 +507,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
506
507
|
}
|
|
507
508
|
}
|
|
508
509
|
nextStep();
|
|
509
|
-
} else if (event
|
|
510
|
+
} else if (isBackNavigationKey(event)) {
|
|
510
511
|
if (step === "portfolio" && portfolioSub === "broker-sync") {
|
|
511
512
|
resetBrokerSync();
|
|
512
513
|
setPortfolioSub("broker-fields");
|
|
@@ -693,9 +694,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
693
694
|
</box>
|
|
694
695
|
</box>
|
|
695
696
|
<box height={1} flexDirection="row" width={contentWidth}>
|
|
696
|
-
<box flexGrow={1}
|
|
697
|
-
{stepIdx > 0 && <text fg={colors.textMuted}>{"<- back"}</text>}
|
|
698
|
-
</box>
|
|
697
|
+
<box flexGrow={1} />
|
|
699
698
|
<box>
|
|
700
699
|
<text fg={colors.textMuted}>{hintText}</text>
|
|
701
700
|
</box>
|
|
@@ -1024,7 +1023,7 @@ function PortfolioStep({
|
|
|
1024
1023
|
<text fg={colors.textDim}>
|
|
1025
1024
|
{brokerSyncing
|
|
1026
1025
|
? "This happens now so your portfolio is ready before onboarding finishes."
|
|
1027
|
-
: "Press Enter to retry, or
|
|
1026
|
+
: "Press Enter to retry, or Backspace to edit the broker settings."}
|
|
1028
1027
|
</text>
|
|
1029
1028
|
</box>
|
|
1030
1029
|
</box>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import type { ColumnConfig } from "../types/config";
|
|
5
|
+
import type { TickerFinancials } from "../types/financials";
|
|
6
|
+
import type { TickerRecord } from "../types/ticker";
|
|
7
|
+
import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
|
|
8
|
+
|
|
9
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
10
|
+
let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
|
|
11
|
+
let resolveCellCallCount = 0;
|
|
12
|
+
|
|
13
|
+
const columns: ColumnConfig[] = [
|
|
14
|
+
{ id: "ticker", label: "Ticker", width: 6, align: "left" },
|
|
15
|
+
];
|
|
16
|
+
const financialsMap = new Map<string, TickerFinancials>();
|
|
17
|
+
const tickers: TickerRecord[] = [
|
|
18
|
+
{ metadata: { ticker: "AAPL", exchange: "NASDAQ", currency: "USD", name: "Apple", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
19
|
+
{ metadata: { ticker: "MSFT", exchange: "NASDAQ", currency: "USD", name: "Microsoft", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
20
|
+
{ metadata: { ticker: "NVDA", exchange: "NASDAQ", currency: "USD", name: "NVIDIA", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function noop(_index: number | null): void {}
|
|
24
|
+
|
|
25
|
+
function resolveCell(_column: ColumnConfig, ticker: TickerRecord, _financials: TickerFinancials | undefined): TickerTableCell {
|
|
26
|
+
resolveCellCallCount += 1;
|
|
27
|
+
return { text: ticker.metadata.ticker };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TickerListTableHarness() {
|
|
31
|
+
const [cursorSymbol, setCursorSymbol] = useState("AAPL");
|
|
32
|
+
setHarnessCursorSymbol = (symbol: string) => setCursorSymbol(symbol);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<TickerListTable
|
|
36
|
+
columns={columns}
|
|
37
|
+
tickers={tickers}
|
|
38
|
+
cursorSymbol={cursorSymbol}
|
|
39
|
+
hoveredIdx={null}
|
|
40
|
+
setHoveredIdx={noop}
|
|
41
|
+
setCursorSymbol={setCursorSymbol}
|
|
42
|
+
resolveCell={resolveCell}
|
|
43
|
+
financialsMap={financialsMap}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
if (testSetup) {
|
|
50
|
+
await act(async () => {
|
|
51
|
+
testSetup!.renderer.destroy();
|
|
52
|
+
});
|
|
53
|
+
testSetup = undefined;
|
|
54
|
+
}
|
|
55
|
+
resolveCellCallCount = 0;
|
|
56
|
+
setHarnessCursorSymbol = null;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("TickerListTable", () => {
|
|
60
|
+
test("recomputes cells only for rows whose selection state changed", async () => {
|
|
61
|
+
testSetup = await testRender(
|
|
62
|
+
<TickerListTableHarness />,
|
|
63
|
+
{ width: 20, height: 6 },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await act(async () => {
|
|
67
|
+
await testSetup!.renderOnce();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const initialCallCount = resolveCellCallCount;
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
setHarnessCursorSymbol?.("MSFT");
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
});
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await testSetup!.renderOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(initialCallCount).toBeGreaterThan(0);
|
|
81
|
+
expect(resolveCellCallCount - initialCallCount).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
});
|