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.
- package/README.md +23 -2
- package/bin/gloomberb +1 -1
- package/package.json +1 -1
- package/src/app.tsx +10 -7
- package/src/capabilities/registry.ts +1 -0
- package/src/capabilities/types.ts +23 -0
- package/src/cli/commands/api.ts +119 -0
- package/src/cli/commands/automation.ts +173 -0
- package/src/cli/commands/command-utils.ts +53 -0
- package/src/cli/commands/market.ts +527 -0
- package/src/cli/commands/overview.ts +222 -0
- package/src/cli/commands/search.ts +39 -0
- package/src/cli/commands/system.ts +553 -0
- package/src/cli/commands/ticker.ts +104 -12
- package/src/cli/context.ts +35 -2
- package/src/cli/entry.ts +51 -0
- package/src/cli/errors.ts +56 -5
- package/src/cli/index.ts +73 -11
- package/src/cli/options.test.ts +38 -0
- package/src/cli/options.ts +95 -0
- package/src/cli/pane-functions/index.ts +10 -1
- package/src/cli/registry.test.ts +40 -0
- package/src/cli/registry.ts +46 -5
- package/src/cli/result.test.ts +82 -0
- package/src/cli/result.ts +149 -0
- package/src/cli/types.ts +11 -0
- package/src/cli.test.ts +11 -11
- package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
- package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
- package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
- package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
- package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
- package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
- package/src/components/chart/core/pane-settings.test.ts +27 -0
- package/src/components/chart/core/pane-settings.ts +60 -1
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
- package/src/components/chart/stock-chart/settings.ts +7 -3
- package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
- package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
- package/src/components/command-bar/surface/pane.test.tsx +76 -0
- package/src/components/data-table/view.tsx +13 -2
- package/src/components/feed-data-table/stack-view.tsx +9 -1
- package/src/components/index.ts +1 -1
- package/src/components/input-search-bar.test.tsx +103 -0
- package/src/components/input-search-bar.tsx +16 -0
- package/src/components/layout/detached-pane-shell.tsx +5 -2
- package/src/components/layout/header.tsx +13 -5
- package/src/components/layout/shell/active-drag.ts +5 -1
- package/src/components/layout/shell/drag/runtime.ts +21 -3
- package/src/components/layout/shell/fullscreen.ts +10 -0
- package/src/components/layout/shell/index.test.tsx +395 -2
- package/src/components/layout/shell/index.tsx +128 -2
- package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
- package/src/components/layout/shell/pane/layers.tsx +26 -14
- package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
- package/src/components/layout/shell/shortcuts.ts +4 -0
- package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
- package/src/components/layout/shell/window-mode/index.ts +11 -3
- package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
- package/src/components/layout/status-bar.test.tsx +105 -0
- package/src/components/layout/status-bar.tsx +32 -6
- package/src/components/layout/transient-layout.tsx +57 -0
- package/src/components/layout/window-controls.tsx +19 -10
- package/src/components/layout/window-edit/mode.test.ts +76 -5
- package/src/components/layout/window-edit/mode.ts +47 -2
- package/src/components/layout/window-edit/presentation.ts +2 -1
- package/src/components/price-performance.tsx +183 -0
- package/src/core/app-services.ts +7 -4
- package/src/data/config/store/index.ts +1 -2
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
- package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
- package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/news/wire/read-state.ts +9 -32
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
- package/src/plugins/builtin/shared/read-state.ts +43 -0
- package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
- package/src/plugins/builtin/substack/article-detail.tsx +39 -15
- package/src/plugins/builtin/substack/article-stack.tsx +6 -2
- package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
- package/src/plugins/builtin/substack/pane.tsx +8 -3
- package/src/plugins/builtin/substack/read-state.ts +55 -0
- package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- package/src/plugins/pane-manager/docking.ts +9 -2
- package/src/plugins/pane-manager/queries.ts +8 -2
- package/src/plugins/prediction-markets/controller/index.ts +2 -0
- package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
- package/src/plugins/prediction-markets/pane.test.tsx +32 -0
- package/src/plugins/prediction-markets/pane.tsx +21 -1
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
- package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +22 -2
- package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
- package/src/renderers/electrobun/bun/window/focus.ts +5 -0
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/app-services.ts +6 -4
- package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
- package/src/renderers/electrobun/view/host/text.test.ts +22 -0
- package/src/renderers/electrobun/view/host/text.tsx +24 -20
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- package/src/renderers/opentui/start.tsx +13 -5
- package/src/state/app/input-capture.ts +12 -0
- package/src/types/plugin.ts +28 -0
- package/src/utils/cli-output.ts +6 -0
- package/src/utils/search-focus-navigation.ts +24 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TransientLayoutState {
|
|
4
|
+
id: "pane-focus";
|
|
5
|
+
label: string;
|
|
6
|
+
active: boolean;
|
|
7
|
+
onActivate?: () => void;
|
|
8
|
+
onDeactivate?: () => void;
|
|
9
|
+
onExit?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TransientLayoutContextValue {
|
|
13
|
+
transientLayout: TransientLayoutState | null;
|
|
14
|
+
setTransientLayout: (layout: TransientLayoutState | null) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const noop = () => {};
|
|
18
|
+
|
|
19
|
+
const TransientLayoutContext = createContext<TransientLayoutContextValue>({
|
|
20
|
+
transientLayout: null,
|
|
21
|
+
setTransientLayout: noop,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function sameTransientLayout(
|
|
25
|
+
left: TransientLayoutState | null,
|
|
26
|
+
right: TransientLayoutState | null,
|
|
27
|
+
): boolean {
|
|
28
|
+
if (left === right) return true;
|
|
29
|
+
if (!left || !right) return false;
|
|
30
|
+
return left.id === right.id
|
|
31
|
+
&& left.label === right.label
|
|
32
|
+
&& left.active === right.active
|
|
33
|
+
&& left.onActivate === right.onActivate
|
|
34
|
+
&& left.onDeactivate === right.onDeactivate
|
|
35
|
+
&& left.onExit === right.onExit;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function TransientLayoutProvider({ children }: { children: ReactNode }) {
|
|
39
|
+
const [transientLayout, setTransientLayoutState] = useState<TransientLayoutState | null>(null);
|
|
40
|
+
const setTransientLayout = useCallback((layout: TransientLayoutState | null) => {
|
|
41
|
+
setTransientLayoutState((current) => (sameTransientLayout(current, layout) ? current : layout));
|
|
42
|
+
}, []);
|
|
43
|
+
const value = useMemo(() => ({
|
|
44
|
+
transientLayout,
|
|
45
|
+
setTransientLayout,
|
|
46
|
+
}), [setTransientLayout, transientLayout]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<TransientLayoutContext.Provider value={value}>
|
|
50
|
+
{children}
|
|
51
|
+
</TransientLayoutContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useTransientLayout(): TransientLayoutContextValue {
|
|
56
|
+
return useContext(TransientLayoutContext);
|
|
57
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Box, useRendererHost } from "../../ui";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { TITLEBAR_OVERLAY_HEIGHT_PX } from "./titlebar-overlay";
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
5
|
+
const WINDOWS_CONTROL_SIZE_PX = TITLEBAR_OVERLAY_HEIGHT_PX;
|
|
6
|
+
export const WINDOWS_CONTROL_GROUP_WIDTH_PX = WINDOWS_CONTROL_SIZE_PX * 3;
|
|
6
7
|
|
|
7
8
|
type WindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
8
9
|
|
|
@@ -44,7 +45,11 @@ function WindowControlIcon({ action }: { action: WindowControlAction }) {
|
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
interface WindowControlsProps {
|
|
49
|
+
windowKind?: "main" | "detached";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function WindowControls({ windowKind = "main" }: WindowControlsProps) {
|
|
48
53
|
const rendererHost = useRendererHost();
|
|
49
54
|
|
|
50
55
|
const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
|
|
@@ -57,11 +62,17 @@ export function WindowControls() {
|
|
|
57
62
|
flexDirection="row"
|
|
58
63
|
alignItems="stretch"
|
|
59
64
|
flexShrink={0}
|
|
60
|
-
|
|
65
|
+
width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`}
|
|
61
66
|
data-gloom-role="window-controls"
|
|
67
|
+
data-window-kind={windowKind}
|
|
62
68
|
aria-hidden={false}
|
|
63
69
|
style={{
|
|
64
|
-
|
|
70
|
+
position: "fixed",
|
|
71
|
+
top: 0,
|
|
72
|
+
right: 0,
|
|
73
|
+
height: `${WINDOWS_CONTROL_SIZE_PX}px`,
|
|
74
|
+
zIndex: 1000,
|
|
75
|
+
backgroundColor: "inherit",
|
|
65
76
|
}}
|
|
66
77
|
>
|
|
67
78
|
{WINDOWS_CONTROLS.map((control) => (
|
|
@@ -69,18 +80,16 @@ export function WindowControls() {
|
|
|
69
80
|
key={control.action}
|
|
70
81
|
alignItems="center"
|
|
71
82
|
justifyContent="center"
|
|
83
|
+
flexShrink={0}
|
|
84
|
+
width={`${WINDOWS_CONTROL_SIZE_PX}px`}
|
|
85
|
+
height="100%"
|
|
72
86
|
data-gloom-role="window-control"
|
|
73
87
|
data-window-control-action={control.action}
|
|
74
88
|
data-gloom-interactive="true"
|
|
75
89
|
role="button"
|
|
76
90
|
aria-label={control.label}
|
|
77
91
|
title={control.label}
|
|
78
|
-
className="electrobun-webkit-app-region-no-drag"
|
|
79
92
|
onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
|
|
80
|
-
style={{
|
|
81
|
-
width: WINDOWS_CONTROL_WIDTH_PX,
|
|
82
|
-
height: "100%",
|
|
83
|
-
}}
|
|
84
93
|
>
|
|
85
94
|
<WindowControlIcon action={control.action} />
|
|
86
95
|
</Box>
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { LayoutConfig } from "../../../types/config";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
cycleWindowEditPane,
|
|
5
|
+
cycleWindowEditTarget,
|
|
6
|
+
getWindowEditPaneIds,
|
|
7
|
+
type WindowEditState,
|
|
8
|
+
} from "./mode";
|
|
9
|
+
import { resolveWindowEditDockMovePreview } from "./presentation";
|
|
4
10
|
|
|
5
11
|
const bounds = { x: 0, y: 0, width: 120, height: 40 };
|
|
6
12
|
|
|
@@ -38,18 +44,83 @@ describe("window edit mode", () => {
|
|
|
38
44
|
dirty: false,
|
|
39
45
|
};
|
|
40
46
|
|
|
41
|
-
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
|
|
47
|
+
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
|
|
42
48
|
expect(state.paneId).toBe("dock-b");
|
|
43
49
|
|
|
44
|
-
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
|
|
50
|
+
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
|
|
45
51
|
expect(state.paneId).toBe("float-a");
|
|
46
52
|
expect(state.previewLayout.floating.find((entry) => entry.instanceId === "float-a")?.zIndex)
|
|
47
53
|
.toBeGreaterThan(state.previewLayout.floating.find((entry) => entry.instanceId === "float-b")?.zIndex ?? 0);
|
|
48
54
|
|
|
49
|
-
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
|
|
55
|
+
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
|
|
50
56
|
expect(state.paneId).toBe("float-b");
|
|
51
57
|
|
|
52
|
-
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
|
|
58
|
+
state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
|
|
53
59
|
expect(state.paneId).toBe("dock-a");
|
|
54
60
|
});
|
|
61
|
+
|
|
62
|
+
test("cycles dock move targets without changing the selected window", () => {
|
|
63
|
+
const layout: LayoutConfig = {
|
|
64
|
+
dockRoot: {
|
|
65
|
+
kind: "split",
|
|
66
|
+
axis: "horizontal",
|
|
67
|
+
ratio: 0.5,
|
|
68
|
+
first: { kind: "pane", instanceId: "source" },
|
|
69
|
+
second: {
|
|
70
|
+
kind: "split",
|
|
71
|
+
axis: "vertical",
|
|
72
|
+
ratio: 0.5,
|
|
73
|
+
first: { kind: "pane", instanceId: "target-a" },
|
|
74
|
+
second: { kind: "pane", instanceId: "target-b" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
instances: [
|
|
78
|
+
pane("source"),
|
|
79
|
+
pane("target-a"),
|
|
80
|
+
pane("target-b"),
|
|
81
|
+
],
|
|
82
|
+
floating: [],
|
|
83
|
+
detached: [],
|
|
84
|
+
};
|
|
85
|
+
const state: WindowEditState = {
|
|
86
|
+
paneId: "source",
|
|
87
|
+
previewLayout: layout,
|
|
88
|
+
mode: "move",
|
|
89
|
+
focus: { kind: "dock-move", targetId: "target-a", position: "right" },
|
|
90
|
+
dirty: false,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const next = cycleWindowEditTarget(state, bounds, { reserveDividerGutters: true }, 1);
|
|
94
|
+
|
|
95
|
+
expect(next.paneId).toBe("source");
|
|
96
|
+
expect(next.focus).toEqual({ kind: "dock-move", targetId: "target-b", position: "right" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("uses dock geometry options for dock move preview rectangles", () => {
|
|
100
|
+
const layout: LayoutConfig = {
|
|
101
|
+
dockRoot: {
|
|
102
|
+
kind: "split",
|
|
103
|
+
axis: "horizontal",
|
|
104
|
+
ratio: 0.5,
|
|
105
|
+
first: { kind: "pane", instanceId: "left" },
|
|
106
|
+
second: { kind: "pane", instanceId: "right" },
|
|
107
|
+
},
|
|
108
|
+
instances: [
|
|
109
|
+
pane("left"),
|
|
110
|
+
pane("right"),
|
|
111
|
+
],
|
|
112
|
+
floating: [],
|
|
113
|
+
detached: [],
|
|
114
|
+
};
|
|
115
|
+
const state: WindowEditState = {
|
|
116
|
+
paneId: "left",
|
|
117
|
+
previewLayout: layout,
|
|
118
|
+
mode: "move",
|
|
119
|
+
focus: { kind: "dock-move", targetId: "right", position: "right" },
|
|
120
|
+
dirty: false,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
expect(resolveWindowEditDockMovePreview(state, bounds, { reserveDividerGutters: true })?.rect)
|
|
124
|
+
.toEqual({ x: 61, y: 0, width: 59, height: 40 });
|
|
125
|
+
});
|
|
55
126
|
});
|
|
@@ -226,12 +226,57 @@ export function cycleWindowEditFocus(
|
|
|
226
226
|
return targets[(currentIndex + delta + targets.length) % targets.length] ?? targets[0]!;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
function getWindowEditDockPaneIds(
|
|
230
|
+
layout: LayoutConfig,
|
|
231
|
+
bounds: LayoutBounds,
|
|
232
|
+
dockGeometryOptions: DockGeometryOptions,
|
|
233
|
+
): string[] {
|
|
234
|
+
return getDockLeafLayouts(layout, bounds, dockGeometryOptions).map((entry) => entry.instanceId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getWindowEditPaneIds(
|
|
238
|
+
layout: LayoutConfig,
|
|
239
|
+
bounds: LayoutBounds,
|
|
240
|
+
dockGeometryOptions: DockGeometryOptions,
|
|
241
|
+
): string[] {
|
|
242
|
+
const dockedIds = getWindowEditDockPaneIds(layout, bounds, dockGeometryOptions);
|
|
231
243
|
const floatingIds = layout.floating.map((entry) => entry.instanceId);
|
|
232
244
|
return [...dockedIds, ...floatingIds];
|
|
233
245
|
}
|
|
234
246
|
|
|
247
|
+
export function cycleWindowEditTarget(
|
|
248
|
+
state: WindowEditState,
|
|
249
|
+
bounds: LayoutBounds,
|
|
250
|
+
dockGeometryOptions: DockGeometryOptions,
|
|
251
|
+
delta: 1 | -1,
|
|
252
|
+
): WindowEditState {
|
|
253
|
+
if (state.mode !== "move" || state.focus.kind !== "dock-move") {
|
|
254
|
+
return state;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const targetIds = getWindowEditDockPaneIds(state.previewLayout, bounds, dockGeometryOptions)
|
|
258
|
+
.filter((paneId) => paneId !== state.paneId);
|
|
259
|
+
if (targetIds.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
...state,
|
|
262
|
+
focus: normalizeWindowEditFocus({ kind: "move" }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const currentIndex = targetIds.indexOf(state.focus.targetId);
|
|
267
|
+
const startIndex = currentIndex >= 0
|
|
268
|
+
? currentIndex
|
|
269
|
+
: delta === 1
|
|
270
|
+
? -1
|
|
271
|
+
: 0;
|
|
272
|
+
const targetId = targetIds[(startIndex + delta + targetIds.length) % targetIds.length] ?? targetIds[0]!;
|
|
273
|
+
return {
|
|
274
|
+
...state,
|
|
275
|
+
focus: normalizeWindowEditFocus({ ...state.focus, targetId }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
|
|
276
|
+
notice: undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
export function raiseWindowEditPane(layout: LayoutConfig, paneId: string): LayoutConfig {
|
|
236
281
|
const floating = layout.floating.find((entry) => entry.instanceId === paneId);
|
|
237
282
|
if (!floating) return layout;
|
|
@@ -73,7 +73,7 @@ export function windowEditStatusLine(
|
|
|
73
73
|
export function windowEditHelpText(state: WindowEditState): string {
|
|
74
74
|
if (state.mode === "move") {
|
|
75
75
|
if (state.focus.kind === "dock-move") {
|
|
76
|
-
return "arrows/hjkl choose side
|
|
76
|
+
return "arrows/hjkl choose side Tab/w target m window d float r resize Enter commit/exit Esc exit/cancel";
|
|
77
77
|
}
|
|
78
78
|
return "Tab/w window d dock/float r resize arrows/hjkl target/move Shift fast Enter commit/exit Esc exit/cancel";
|
|
79
79
|
}
|
|
@@ -100,6 +100,7 @@ export function resolveWindowEditDockMovePreview(
|
|
|
100
100
|
state.paneId,
|
|
101
101
|
leafDropTarget(focus.targetId, focus.position),
|
|
102
102
|
bounds,
|
|
103
|
+
dockGeometryOptions,
|
|
103
104
|
);
|
|
104
105
|
if (!simulation.previewRect) return null;
|
|
105
106
|
return {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Box, ScrollBox, Text } from "../ui";
|
|
2
|
+
import { blendHex, colors, priceColor } from "../theme/colors";
|
|
3
|
+
import { displayWidth, formatPercent, padTo } from "../utils/format";
|
|
4
|
+
import type { PriceReturnField } from "../market-data/performance";
|
|
5
|
+
|
|
6
|
+
type MouseInteractionEvent = {
|
|
7
|
+
stopPropagation?: () => void;
|
|
8
|
+
preventDefault?: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const STRIP_COLUMN_GAP = 1;
|
|
12
|
+
const RETURN_CELL_MIN_WIDTH = 7;
|
|
13
|
+
const SYMBOL_COLUMN_MIN_WIDTH = 6;
|
|
14
|
+
const SYMBOL_COLUMN_MAX_WIDTH = 12;
|
|
15
|
+
|
|
16
|
+
export interface PriceReturnTableRow {
|
|
17
|
+
symbol: string;
|
|
18
|
+
color: string;
|
|
19
|
+
fields: PriceReturnField[];
|
|
20
|
+
selected: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatReturnValue(value: number | null): string {
|
|
24
|
+
return value == null ? "-" : formatPercent(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function returnValueColor(value: number | null): string {
|
|
28
|
+
return value == null ? colors.textMuted : priceColor(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasAnyReturn(fields: readonly PriceReturnField[]): boolean {
|
|
32
|
+
return fields.some((field) => field.value != null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function chunkFields<T>(fields: readonly T[], size: number): T[][] {
|
|
36
|
+
const rows: T[][] = [];
|
|
37
|
+
for (let index = 0; index < fields.length; index += size) {
|
|
38
|
+
rows.push(fields.slice(index, index + size));
|
|
39
|
+
}
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function PriceReturnStrip({
|
|
44
|
+
fields,
|
|
45
|
+
width,
|
|
46
|
+
}: {
|
|
47
|
+
fields: PriceReturnField[];
|
|
48
|
+
width: number;
|
|
49
|
+
}) {
|
|
50
|
+
if (!hasAnyReturn(fields)) return null;
|
|
51
|
+
|
|
52
|
+
const columnCount = width >= 72
|
|
53
|
+
? Math.min(6, fields.length)
|
|
54
|
+
: width >= 54
|
|
55
|
+
? Math.min(4, fields.length)
|
|
56
|
+
: width >= 36
|
|
57
|
+
? Math.min(3, fields.length)
|
|
58
|
+
: Math.min(2, fields.length);
|
|
59
|
+
const availableWidth = Math.max(width - STRIP_COLUMN_GAP * Math.max(columnCount - 1, 0), columnCount);
|
|
60
|
+
const cellWidth = Math.max(RETURN_CELL_MIN_WIDTH, Math.floor(availableWidth / columnCount));
|
|
61
|
+
const rows = chunkFields(fields, columnCount);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column" width={width}>
|
|
65
|
+
{rows.map((row, rowIndex) => (
|
|
66
|
+
<Box key={rowIndex} flexDirection="row" height={1}>
|
|
67
|
+
{row.map((field, columnIndex) => {
|
|
68
|
+
const labelWidth = Math.min(3, Math.max(2, displayWidth(field.label)));
|
|
69
|
+
const valueWidth = Math.max(1, cellWidth - labelWidth);
|
|
70
|
+
return (
|
|
71
|
+
<Box key={field.id} flexDirection="row">
|
|
72
|
+
{columnIndex > 0 && <Box width={STRIP_COLUMN_GAP} />}
|
|
73
|
+
<Box flexDirection="row" width={cellWidth}>
|
|
74
|
+
<Text fg={colors.textDim}>{padTo(field.label, labelWidth)}</Text>
|
|
75
|
+
<Box width={valueWidth} overflow="hidden">
|
|
76
|
+
<Text fg={returnValueColor(field.value)}>{padTo(formatReturnValue(field.value), valueWidth, "right")}</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
</Box>
|
|
79
|
+
</Box>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</Box>
|
|
83
|
+
))}
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getFieldById(fields: readonly PriceReturnField[], id: string): PriceReturnField | null {
|
|
89
|
+
return fields.find((field) => field.id === id) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function chooseComparisonFieldIds(width: number, availableIds: readonly string[]): string[] {
|
|
93
|
+
const preferred = width >= 86
|
|
94
|
+
? ["RNG", "1M", "3M", "6M", "1Y", "3Y", "5Y"]
|
|
95
|
+
: width >= 72
|
|
96
|
+
? ["RNG", "1M", "3M", "1Y", "3Y", "5Y"]
|
|
97
|
+
: width >= 58
|
|
98
|
+
? ["RNG", "1M", "1Y", "3Y", "5Y"]
|
|
99
|
+
: width >= 44
|
|
100
|
+
? ["RNG", "1M", "1Y", "5Y"]
|
|
101
|
+
: ["1Y", "5Y"];
|
|
102
|
+
const available = new Set(availableIds);
|
|
103
|
+
return preferred.filter((id) => available.has(id));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function PriceReturnTable({
|
|
107
|
+
height,
|
|
108
|
+
onFocusInteraction,
|
|
109
|
+
onOpenSymbol,
|
|
110
|
+
onSelectSymbol,
|
|
111
|
+
rows,
|
|
112
|
+
width,
|
|
113
|
+
}: {
|
|
114
|
+
height: number;
|
|
115
|
+
onFocusInteraction?: (event: MouseInteractionEvent | null | undefined) => void;
|
|
116
|
+
onOpenSymbol: (symbol: string) => void;
|
|
117
|
+
onSelectSymbol: (symbol: string) => void;
|
|
118
|
+
rows: PriceReturnTableRow[];
|
|
119
|
+
width: number;
|
|
120
|
+
}) {
|
|
121
|
+
if (height <= 0 || rows.length === 0) return null;
|
|
122
|
+
|
|
123
|
+
const availableIds = rows[0]?.fields.map((field) => field.id) ?? [];
|
|
124
|
+
const fieldIds = chooseComparisonFieldIds(width, availableIds);
|
|
125
|
+
if (fieldIds.length === 0) return null;
|
|
126
|
+
|
|
127
|
+
const valueWidth = Math.max(
|
|
128
|
+
RETURN_CELL_MIN_WIDTH,
|
|
129
|
+
Math.floor((width - SYMBOL_COLUMN_MIN_WIDTH - fieldIds.length) / Math.max(fieldIds.length, 1)),
|
|
130
|
+
);
|
|
131
|
+
const symbolWidth = Math.min(
|
|
132
|
+
SYMBOL_COLUMN_MAX_WIDTH,
|
|
133
|
+
Math.max(SYMBOL_COLUMN_MIN_WIDTH, width - fieldIds.length * (valueWidth + 1)),
|
|
134
|
+
);
|
|
135
|
+
const fieldsById = rows[0]?.fields ?? [];
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<ScrollBox height={height} scrollY>
|
|
139
|
+
<Box flexDirection="column" width={width}>
|
|
140
|
+
<Box flexDirection="row" height={1}>
|
|
141
|
+
<Text fg={colors.textDim}>{padTo("Sym", symbolWidth)}</Text>
|
|
142
|
+
{fieldIds.map((fieldId) => {
|
|
143
|
+
const field = getFieldById(fieldsById, fieldId);
|
|
144
|
+
return (
|
|
145
|
+
<Box key={fieldId} flexDirection="row">
|
|
146
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
147
|
+
<Text fg={colors.textDim}>{padTo(field?.label ?? fieldId, valueWidth, "right")}</Text>
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</Box>
|
|
152
|
+
|
|
153
|
+
{rows.map((row) => (
|
|
154
|
+
<Box
|
|
155
|
+
key={row.symbol}
|
|
156
|
+
flexDirection="row"
|
|
157
|
+
height={1}
|
|
158
|
+
width={width}
|
|
159
|
+
backgroundColor={row.selected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
|
|
160
|
+
onMouseMove={() => onSelectSymbol(row.symbol)}
|
|
161
|
+
onMouseDown={(event: any) => {
|
|
162
|
+
onFocusInteraction?.(event);
|
|
163
|
+
onSelectSymbol(row.symbol);
|
|
164
|
+
onOpenSymbol(row.symbol);
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<Text fg={row.color}>{padTo(`${row.selected ? ">" : " "} ${row.symbol}`, symbolWidth)}</Text>
|
|
168
|
+
{fieldIds.map((fieldId) => {
|
|
169
|
+
const field = getFieldById(row.fields, fieldId);
|
|
170
|
+
const value = field?.value ?? null;
|
|
171
|
+
return (
|
|
172
|
+
<Box key={fieldId} flexDirection="row">
|
|
173
|
+
<Text fg={colors.textDim}>{" "}</Text>
|
|
174
|
+
<Text fg={returnValueColor(value)}>{padTo(formatReturnValue(value), valueWidth, "right")}</Text>
|
|
175
|
+
</Box>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</Box>
|
|
179
|
+
))}
|
|
180
|
+
</Box>
|
|
181
|
+
</ScrollBox>
|
|
182
|
+
);
|
|
183
|
+
}
|
package/src/core/app-services.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { assetDataProvider, newsProvider } from "../capabilities";
|
|
|
12
12
|
import type { AppConfig } from "../types/config";
|
|
13
13
|
import type { DataProvider } from "../types/data-provider";
|
|
14
14
|
import { debugLog } from "../utils/debug-log";
|
|
15
|
-
import { measurePerf } from "../utils/perf-marks";
|
|
15
|
+
import { measurePerf, measurePerfAsync } from "../utils/perf-marks";
|
|
16
16
|
import { setIbkrPortfolioPerformanceResourceStore } from "../plugins/ibkr/portfolio-performance";
|
|
17
17
|
|
|
18
18
|
const servicesLog = debugLog.createLogger("services");
|
|
@@ -25,6 +25,7 @@ export interface AppServices {
|
|
|
25
25
|
marketData: MarketDataCoordinator;
|
|
26
26
|
pluginRegistry: PluginRegistry;
|
|
27
27
|
newsService: NewsService;
|
|
28
|
+
ready: Promise<void>;
|
|
28
29
|
destroy(): void;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -68,10 +69,11 @@ export function createAppServices({
|
|
|
68
69
|
setSharedMarketDataCoordinator(marketData);
|
|
69
70
|
|
|
70
71
|
const plugins = getLoadablePlugins(externalPlugins);
|
|
72
|
+
const pluginReadyPromises: Promise<void>[] = [];
|
|
71
73
|
for (const plugin of plugins) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
pluginReadyPromises.push(measurePerfAsync("startup.services.register-plugin", () => (
|
|
75
|
+
pluginRegistry.register(plugin)
|
|
76
|
+
), { pluginId: plugin.id }));
|
|
75
77
|
}
|
|
76
78
|
measurePerf("startup.services.news-start", () => {
|
|
77
79
|
newsService.start();
|
|
@@ -86,6 +88,7 @@ export function createAppServices({
|
|
|
86
88
|
marketData,
|
|
87
89
|
pluginRegistry,
|
|
88
90
|
newsService,
|
|
91
|
+
ready: Promise.all(pluginReadyPromises).then(() => {}),
|
|
89
92
|
destroy() {
|
|
90
93
|
setSharedMarketDataCoordinator(null);
|
|
91
94
|
setSharedNewsService(null);
|
|
@@ -21,8 +21,7 @@ export function setConfigStoreHost(host: ConfigStoreHost | null): void {
|
|
|
21
21
|
|
|
22
22
|
async function loadNodeHost(): Promise<ConfigStoreHost> {
|
|
23
23
|
if (!nodeHostPromise) {
|
|
24
|
-
|
|
25
|
-
nodeHostPromise = import(modulePath) as Promise<ConfigStoreHost>;
|
|
24
|
+
nodeHostPromise = import("./node") as Promise<ConfigStoreHost>;
|
|
26
25
|
}
|
|
27
26
|
return nodeHostPromise;
|
|
28
27
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendQuoteToPriceReturnHistory,
|
|
4
|
+
buildPriceReturnFields,
|
|
5
|
+
computePriceReturnForHorizon,
|
|
6
|
+
PRICE_RETURN_HORIZONS,
|
|
7
|
+
} from "./performance";
|
|
8
|
+
import type { PricePoint } from "../types/financials";
|
|
9
|
+
|
|
10
|
+
function point(date: string, close: number): PricePoint {
|
|
11
|
+
return { date: new Date(`${date}T00:00:00Z`), close };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("price performance", () => {
|
|
15
|
+
test("computes horizon return from the closest prior baseline", () => {
|
|
16
|
+
const history = [
|
|
17
|
+
point("2025-12-31", 100),
|
|
18
|
+
point("2026-01-02", 102),
|
|
19
|
+
point("2026-01-31", 110),
|
|
20
|
+
point("2026-02-01", 120),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const oneMonth = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1M")!;
|
|
24
|
+
|
|
25
|
+
expect(computePriceReturnForHorizon(history, oneMonth)).toBeCloseTo(20 / 100);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("leaves fixed horizons empty when history does not reach the baseline date", () => {
|
|
29
|
+
const history = [
|
|
30
|
+
point("2026-01-15", 100),
|
|
31
|
+
point("2026-02-01", 110),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const fields = buildPriceReturnFields(history);
|
|
35
|
+
|
|
36
|
+
expect(fields.find((field) => field.id === "1M")?.value).toBeNull();
|
|
37
|
+
expect(fields.find((field) => field.id === "1Y")?.value).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("can include a newer live quote as the latest return point", () => {
|
|
41
|
+
const history = [
|
|
42
|
+
point("2025-01-31", 100),
|
|
43
|
+
point("2026-01-31", 120),
|
|
44
|
+
];
|
|
45
|
+
const withQuote = appendQuoteToPriceReturnHistory(history, {
|
|
46
|
+
price: 150,
|
|
47
|
+
lastUpdated: Date.parse("2026-02-01T15:30:00Z"),
|
|
48
|
+
});
|
|
49
|
+
const oneYear = PRICE_RETURN_HORIZONS.find((horizon) => horizon.id === "1Y")!;
|
|
50
|
+
|
|
51
|
+
expect(withQuote).toHaveLength(3);
|
|
52
|
+
expect(computePriceReturnForHorizon(withQuote, oneYear)).toBeCloseTo(50 / 100);
|
|
53
|
+
});
|
|
54
|
+
});
|