gloomberb 0.4.1 → 0.5.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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +168 -65
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +292 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/header.tsx +2 -1
- package/src/components/layout/pane-sizing.ts +10 -0
- package/src/components/layout/pane.tsx +9 -2
- package/src/components/layout/shell.test.tsx +282 -28
- package/src/components/layout/shell.tsx +10 -7
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +54 -1
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +60 -3
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +83 -18
- package/src/plugins/builtin/chat-controller.ts +26 -14
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +424 -120
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +24 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +1 -1
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +8 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +13 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
- package/src/plugins/registry.ts +22 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/utils/api-client.test.ts +90 -0
- package/src/utils/api-client.ts +56 -16
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveFloating,
|
|
20
20
|
simulateDrop,
|
|
21
21
|
swapPanes,
|
|
22
|
+
type DockDividerLayout,
|
|
22
23
|
type DockLeafLayout,
|
|
23
24
|
type DropTarget,
|
|
24
25
|
type FloatingRect,
|
|
@@ -38,7 +39,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
|
|
|
38
39
|
import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
|
|
39
40
|
import { FloatingPaneWrapper } from "./floating-pane";
|
|
40
41
|
import { PaneWrapper } from "./pane";
|
|
41
|
-
import { getPaneBodyHeight } from "./pane-sizing";
|
|
42
|
+
import { getPaneBodyHeight, getPaneBodyWidth } from "./pane-sizing";
|
|
42
43
|
|
|
43
44
|
interface ShellProps {
|
|
44
45
|
pluginRegistry: PluginRegistry;
|
|
@@ -778,8 +779,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
778
779
|
event.preventDefault();
|
|
779
780
|
return;
|
|
780
781
|
}
|
|
781
|
-
|
|
782
|
-
|
|
782
|
+
// Focus floating panes without consuming body clicks so child controls
|
|
783
|
+
// can act on the same initial mouse interaction.
|
|
783
784
|
return;
|
|
784
785
|
}
|
|
785
786
|
|
|
@@ -837,8 +838,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
837
838
|
event.preventDefault();
|
|
838
839
|
return;
|
|
839
840
|
}
|
|
840
|
-
|
|
841
|
-
|
|
841
|
+
// Focus docked panes without consuming body clicks so embedded widgets
|
|
842
|
+
// receive the first click instead of requiring a second one.
|
|
842
843
|
return;
|
|
843
844
|
}
|
|
844
845
|
|
|
@@ -986,6 +987,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
986
987
|
const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
|
|
987
988
|
const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
|
|
988
989
|
const bodyHeight = getPaneBodyHeight(leaf.rect.height);
|
|
990
|
+
const bodyWidth = getPaneBodyWidth(leaf.rect.width, focused);
|
|
989
991
|
return (
|
|
990
992
|
<box
|
|
991
993
|
key={`dock:${leaf.instanceId}`}
|
|
@@ -1008,7 +1010,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1008
1010
|
paneId={pane.instance.instanceId}
|
|
1009
1011
|
paneType={pane.instance.paneId}
|
|
1010
1012
|
focused={focused}
|
|
1011
|
-
width={
|
|
1013
|
+
width={bodyWidth}
|
|
1012
1014
|
height={bodyHeight}
|
|
1013
1015
|
/>
|
|
1014
1016
|
</PaneInstanceProvider>
|
|
@@ -1022,6 +1024,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1022
1024
|
const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
|
|
1023
1025
|
const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
|
|
1024
1026
|
const bodyHeight = getPaneBodyHeight(preview.height);
|
|
1027
|
+
const bodyWidth = getPaneBodyWidth(preview.width, focused);
|
|
1025
1028
|
return (
|
|
1026
1029
|
<FloatingPaneWrapper
|
|
1027
1030
|
key={`float:${pane.instance.instanceId}`}
|
|
@@ -1040,7 +1043,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1040
1043
|
paneId={pane.instance.instanceId}
|
|
1041
1044
|
paneType={pane.instance.paneId}
|
|
1042
1045
|
focused={focused}
|
|
1043
|
-
width={
|
|
1046
|
+
width={bodyWidth}
|
|
1044
1047
|
height={bodyHeight}
|
|
1045
1048
|
close={() => handleFloatingClose(pane.instance.instanceId)}
|
|
1046
1049
|
/>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { testRender } from "@opentui/react/test-utils";
|
|
3
3
|
import { AppContext, createInitialState } from "../../state/app-context";
|
|
4
|
-
import { cloneLayout, createDefaultConfig } from "../../types/config";
|
|
4
|
+
import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../types/config";
|
|
5
|
+
import type { AppNotificationRequest } from "../../types/plugin";
|
|
5
6
|
import { StatusBar } from "./status-bar";
|
|
6
7
|
import { setSharedRegistryForTests } from "../../plugins/registry";
|
|
7
8
|
|
|
@@ -89,13 +90,13 @@ describe("StatusBar", () => {
|
|
|
89
90
|
|
|
90
91
|
const actions: Array<{ type: string }> = [];
|
|
91
92
|
let updatedLayout = null as ReturnType<typeof cloneLayout> | null;
|
|
92
|
-
const
|
|
93
|
+
const notifications: AppNotificationRequest[] = [];
|
|
93
94
|
|
|
94
95
|
setSharedRegistryForTests({
|
|
95
96
|
getLayoutFn: () => state.config.layout,
|
|
96
97
|
getTermSizeFn: () => ({ width: 120, height: 40 }),
|
|
97
|
-
updateLayoutFn: (layout) => { updatedLayout = layout; },
|
|
98
|
-
|
|
98
|
+
updateLayoutFn: (layout: LayoutConfig) => { updatedLayout = layout; },
|
|
99
|
+
notify: (notification: AppNotificationRequest) => { notifications.push(notification); },
|
|
99
100
|
Slot: () => null,
|
|
100
101
|
} as any);
|
|
101
102
|
|
|
@@ -119,8 +120,16 @@ describe("StatusBar", () => {
|
|
|
119
120
|
await testSetup.renderOnce();
|
|
120
121
|
|
|
121
122
|
expect(updatedLayout?.floating).toHaveLength(0);
|
|
122
|
-
expect(
|
|
123
|
+
expect(notifications).toHaveLength(1);
|
|
124
|
+
expect(notifications[0]).toMatchObject({
|
|
125
|
+
body: "Retiled all panes",
|
|
126
|
+
type: "success",
|
|
127
|
+
action: { label: "Revert" },
|
|
128
|
+
});
|
|
123
129
|
expect(actions).toContainEqual({ type: "DISMISS_GRIDLOCK_TIP" });
|
|
130
|
+
|
|
131
|
+
notifications[0]!.action!.onClick();
|
|
132
|
+
expect(actions).toContainEqual({ type: "UNDO_LAYOUT" });
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
test("auto-dismisses the gridlock tip after its timeout", async () => {
|
|
@@ -136,7 +145,7 @@ describe("StatusBar", () => {
|
|
|
136
145
|
const originalSetTimeout = globalThis.setTimeout;
|
|
137
146
|
const originalClearTimeout = globalThis.clearTimeout;
|
|
138
147
|
|
|
139
|
-
globalThis.setTimeout = ((callback:
|
|
148
|
+
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number) => {
|
|
140
149
|
timers.push({ callback: typeof callback === "function" ? callback : null, delay });
|
|
141
150
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
142
151
|
}) as typeof setTimeout;
|
|
@@ -146,7 +155,7 @@ describe("StatusBar", () => {
|
|
|
146
155
|
getLayoutFn: () => state.config.layout,
|
|
147
156
|
getTermSizeFn: () => ({ width: 120, height: 40 }),
|
|
148
157
|
updateLayoutFn: () => {},
|
|
149
|
-
|
|
158
|
+
notify: () => {},
|
|
150
159
|
Slot: () => null,
|
|
151
160
|
} as any);
|
|
152
161
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../../utils/market-status";
|
|
11
11
|
import { getSharedRegistry } from "../../plugins/registry";
|
|
12
12
|
import { gridlockAllPanes } from "../../plugins/pane-manager";
|
|
13
|
+
import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
|
|
13
14
|
|
|
14
15
|
const GRIDLOCK_TIP_DURATION_MS = 60_000;
|
|
15
16
|
|
|
@@ -60,7 +61,9 @@ export function StatusBar() {
|
|
|
60
61
|
if (!registry) return;
|
|
61
62
|
const { width, height } = registry.getTermSizeFn();
|
|
62
63
|
registry.updateLayoutFn(gridlockAllPanes(registry.getLayoutFn(), { x: 0, y: 0, width, height }));
|
|
63
|
-
registry.
|
|
64
|
+
notifyGridlockComplete(registry.notify.bind(registry), () => {
|
|
65
|
+
dispatch({ type: "UNDO_LAYOUT" });
|
|
66
|
+
});
|
|
64
67
|
dispatch({ type: "DISMISS_GRIDLOCK_TIP" });
|
|
65
68
|
};
|
|
66
69
|
|
|
@@ -3,15 +3,12 @@ import { useDialog, useDialogKeyboard, type AlertContext } from "@opentui-ui/dia
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import type {
|
|
5
5
|
PaneSettingField,
|
|
6
|
-
PaneSettingOption,
|
|
7
|
-
PaneSettingOrderedMultiSelectField,
|
|
8
6
|
PaneSettingTextField,
|
|
9
7
|
} from "../types/plugin";
|
|
10
8
|
import type { PluginRegistry } from "../plugins/registry";
|
|
11
9
|
import { useAppState } from "../state/app-context";
|
|
12
10
|
import { colors } from "../theme/colors";
|
|
13
|
-
import {
|
|
14
|
-
import { Button, DialogFrame, ListView, TextField } from "./ui";
|
|
11
|
+
import { Button, DialogFrame, ListView, MultiSelectDialogContent, TextField } from "./ui";
|
|
15
12
|
|
|
16
13
|
interface PaneSettingsDialogContentProps extends AlertContext {
|
|
17
14
|
paneId: string;
|
|
@@ -56,37 +53,6 @@ function coerceSelectedValues(value: unknown): string[] {
|
|
|
56
53
|
: [];
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
function toggleSelectedValue(currentValues: string[], value: string): string[] {
|
|
60
|
-
return currentValues.includes(value)
|
|
61
|
-
? currentValues.filter((entry) => entry !== value)
|
|
62
|
-
: [...currentValues, value];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function moveSelectedValue(
|
|
66
|
-
field: PaneSettingOrderedMultiSelectField,
|
|
67
|
-
currentValues: string[],
|
|
68
|
-
selectedOption: string,
|
|
69
|
-
direction: "up" | "down",
|
|
70
|
-
): string[] {
|
|
71
|
-
if (!currentValues.includes(selectedOption)) return currentValues;
|
|
72
|
-
|
|
73
|
-
const optionValueSet = new Set(field.options.map((option) => option.value));
|
|
74
|
-
const ordered = currentValues.filter((value) => optionValueSet.has(value));
|
|
75
|
-
const index = ordered.indexOf(selectedOption);
|
|
76
|
-
if (index < 0) return currentValues;
|
|
77
|
-
|
|
78
|
-
const targetIndex = direction === "up"
|
|
79
|
-
? Math.max(0, index - 1)
|
|
80
|
-
: Math.min(ordered.length - 1, index + 1);
|
|
81
|
-
if (targetIndex === index) return currentValues;
|
|
82
|
-
|
|
83
|
-
const next = [...ordered];
|
|
84
|
-
const [entry] = next.splice(index, 1);
|
|
85
|
-
next.splice(targetIndex, 0, entry!);
|
|
86
|
-
const unknownValues = currentValues.filter((value) => !optionValueSet.has(value));
|
|
87
|
-
return [...next, ...unknownValues];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
56
|
function SelectFieldDialog({
|
|
91
57
|
dismiss,
|
|
92
58
|
dialogId,
|
|
@@ -116,7 +82,7 @@ function SelectFieldDialog({
|
|
|
116
82
|
return (
|
|
117
83
|
<DialogFrame
|
|
118
84
|
title={field.label}
|
|
119
|
-
footer="Use ↑↓ to choose · enter to apply · esc
|
|
85
|
+
footer="Use ↑↓ to choose · enter to apply · esc cancel"
|
|
120
86
|
>
|
|
121
87
|
<ListView
|
|
122
88
|
items={field.options.map((option) => ({
|
|
@@ -175,7 +141,7 @@ function TextFieldDialog({
|
|
|
175
141
|
return (
|
|
176
142
|
<DialogFrame
|
|
177
143
|
title={field.label}
|
|
178
|
-
footer="Enter to apply · esc
|
|
144
|
+
footer="Enter to apply · esc cancel"
|
|
179
145
|
>
|
|
180
146
|
<box flexDirection="column" gap={1}>
|
|
181
147
|
<TextField
|
|
@@ -216,121 +182,16 @@ function MultiSelectFieldDialog({
|
|
|
216
182
|
currentValue: unknown;
|
|
217
183
|
onApply: (value: string[]) => Promise<void>;
|
|
218
184
|
}) {
|
|
219
|
-
const optionByValue = new Map(field.options.map((option) => [option.value, option]));
|
|
220
|
-
const [selectedValues, setSelectedValues] = useState(() => coerceSelectedValues(currentValue));
|
|
221
|
-
const knownSelectedValues = selectedValues.filter((value) => optionByValue.has(value));
|
|
222
|
-
const orderedOptions = field.options;
|
|
223
|
-
const [selectedOptionId, setSelectedOptionId] = useState(orderedOptions[0]?.value ?? "");
|
|
224
|
-
const selectedIndex = Math.max(0, orderedOptions.findIndex((option) => option.value === selectedOptionId));
|
|
225
|
-
const selectedOptionValue = orderedOptions[selectedIndex]?.value ?? "";
|
|
226
|
-
const selectedValueOrder = knownSelectedValues.indexOf(selectedOptionValue);
|
|
227
|
-
const canMoveUp = field.type === "ordered-multi-select" && selectedValueOrder > 0;
|
|
228
|
-
const canMoveDown = field.type === "ordered-multi-select"
|
|
229
|
-
&& selectedValueOrder >= 0
|
|
230
|
-
&& selectedValueOrder < knownSelectedValues.length - 1;
|
|
231
|
-
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
setSelectedValues(coerceSelectedValues(currentValue));
|
|
234
|
-
}, [currentValue]);
|
|
235
|
-
|
|
236
|
-
useEffect(() => {
|
|
237
|
-
if (orderedOptions.some((option) => option.value === selectedOptionId)) return;
|
|
238
|
-
setSelectedOptionId(orderedOptions[0]?.value ?? "");
|
|
239
|
-
}, [orderedOptions, selectedOptionId]);
|
|
240
|
-
|
|
241
|
-
const toggleItems = orderedOptions.map((option) => {
|
|
242
|
-
const order = knownSelectedValues.indexOf(option.value);
|
|
243
|
-
const orderDescription = field.type === "ordered-multi-select" && order >= 0
|
|
244
|
-
? `Order ${order + 1} of ${knownSelectedValues.length}.`
|
|
245
|
-
: null;
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
id: option.value,
|
|
249
|
-
label: option.label,
|
|
250
|
-
enabled: selectedValues.includes(option.value),
|
|
251
|
-
description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
const listHeight = Math.min(12, Math.max(6, toggleItems.length));
|
|
255
|
-
|
|
256
|
-
const applySelectedValues = async (nextValues: string[]) => {
|
|
257
|
-
const previousValues = selectedValues;
|
|
258
|
-
setSelectedValues(nextValues);
|
|
259
|
-
try {
|
|
260
|
-
await onApply(nextValues);
|
|
261
|
-
} catch (error) {
|
|
262
|
-
setSelectedValues(previousValues);
|
|
263
|
-
throw error;
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const toggleOption = async (option: PaneSettingOption | undefined) => {
|
|
268
|
-
if (!option) return;
|
|
269
|
-
await applySelectedValues(toggleSelectedValue(selectedValues, option.value));
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const moveOption = async (direction: "up" | "down") => {
|
|
273
|
-
if (field.type !== "ordered-multi-select") return;
|
|
274
|
-
const option = orderedOptions[selectedIndex];
|
|
275
|
-
if (!option) return;
|
|
276
|
-
await applySelectedValues(moveSelectedValue(field, selectedValues, option.value, direction));
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
useDialogKeyboard((event) => {
|
|
280
|
-
event.stopPropagation();
|
|
281
|
-
if (event.name === "up" || event.name === "k") {
|
|
282
|
-
const nextIndex = Math.max(0, selectedIndex - 1);
|
|
283
|
-
setSelectedOptionId(orderedOptions[nextIndex]?.value ?? selectedOptionId);
|
|
284
|
-
} else if (event.name === "down" || event.name === "j") {
|
|
285
|
-
const nextIndex = Math.min(orderedOptions.length - 1, selectedIndex + 1);
|
|
286
|
-
setSelectedOptionId(orderedOptions[nextIndex]?.value ?? selectedOptionId);
|
|
287
|
-
}
|
|
288
|
-
else if (isSpaceKey(event)) {
|
|
289
|
-
void toggleOption(orderedOptions[selectedIndex]).catch(() => {});
|
|
290
|
-
} else if (event.name === "[" && field.type === "ordered-multi-select") {
|
|
291
|
-
void moveOption("up").catch(() => {});
|
|
292
|
-
} else if (event.name === "]" && field.type === "ordered-multi-select") {
|
|
293
|
-
void moveOption("down").catch(() => {});
|
|
294
|
-
} else if (event.name === "enter" || event.name === "return") {
|
|
295
|
-
dismiss();
|
|
296
|
-
} else if (event.name === "escape") {
|
|
297
|
-
dismiss();
|
|
298
|
-
}
|
|
299
|
-
}, dialogId);
|
|
300
|
-
|
|
301
185
|
return (
|
|
302
|
-
<
|
|
186
|
+
<MultiSelectDialogContent
|
|
187
|
+
dismiss={dismiss}
|
|
188
|
+
dialogId={dialogId}
|
|
303
189
|
title={field.label}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<ToggleList
|
|
310
|
-
items={toggleItems}
|
|
311
|
-
selectedIdx={selectedIndex}
|
|
312
|
-
bgColor={colors.commandBg}
|
|
313
|
-
height={listHeight}
|
|
314
|
-
scrollable
|
|
315
|
-
showSelectedDescription={false}
|
|
316
|
-
onSelect={(index) => setSelectedOptionId(orderedOptions[index]?.value ?? selectedOptionId)}
|
|
317
|
-
onToggle={(id) => {
|
|
318
|
-
setSelectedOptionId(id);
|
|
319
|
-
void toggleOption(optionByValue.get(id)).catch(() => {});
|
|
320
|
-
}}
|
|
321
|
-
/>
|
|
322
|
-
<box flexDirection="row" gap={1}>
|
|
323
|
-
<Button label="Toggle" variant="secondary" onPress={() => { void toggleOption(orderedOptions[selectedIndex]).catch(() => {}); }} />
|
|
324
|
-
{field.type === "ordered-multi-select" && (
|
|
325
|
-
<>
|
|
326
|
-
<Button label="Move Up" variant="ghost" disabled={!canMoveUp} onPress={() => { void moveOption("up").catch(() => {}); }} />
|
|
327
|
-
<Button label="Move Down" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
|
|
328
|
-
</>
|
|
329
|
-
)}
|
|
330
|
-
<Button label="Done" variant="primary" onPress={dismiss} />
|
|
331
|
-
</box>
|
|
332
|
-
</box>
|
|
333
|
-
</DialogFrame>
|
|
190
|
+
options={field.options}
|
|
191
|
+
selectedValues={coerceSelectedValues(currentValue)}
|
|
192
|
+
onChange={onApply}
|
|
193
|
+
ordered={field.type === "ordered-multi-select"}
|
|
194
|
+
/>
|
|
334
195
|
);
|
|
335
196
|
}
|
|
336
197
|
|
|
@@ -415,7 +276,7 @@ export function PaneSettingsDialogContent({
|
|
|
415
276
|
|
|
416
277
|
if (!descriptor) {
|
|
417
278
|
return (
|
|
418
|
-
<DialogFrame title="Pane Settings" footer="Press esc to
|
|
279
|
+
<DialogFrame title="Pane Settings" footer="Press esc to cancel">
|
|
419
280
|
<text fg={colors.textDim}>This pane is no longer configurable.</text>
|
|
420
281
|
</DialogFrame>
|
|
421
282
|
);
|
|
@@ -424,7 +285,7 @@ export function PaneSettingsDialogContent({
|
|
|
424
285
|
const title = descriptor.settingsDef.title ?? `${descriptor.paneDef.name} Settings`;
|
|
425
286
|
|
|
426
287
|
return (
|
|
427
|
-
<DialogFrame title={title} footer="Use ↑↓ to choose · enter to edit · esc
|
|
288
|
+
<DialogFrame title={title} footer="Use ↑↓ to choose · enter to edit · esc cancel">
|
|
428
289
|
<ListView
|
|
429
290
|
items={fields.map((field) => ({
|
|
430
291
|
id: field.key,
|
|
@@ -4,6 +4,7 @@ import type { InputRenderable } from "@opentui/core";
|
|
|
4
4
|
import { useDialogKeyboard, type PromptContext } from "@opentui-ui/dialog/react";
|
|
5
5
|
import type { Quote } from "../types/financials";
|
|
6
6
|
import { colors } from "../theme/colors";
|
|
7
|
+
import { formatMarketPrice } from "../utils/market-format";
|
|
7
8
|
import { DialogFrame, ListView, NumberField } from "./ui";
|
|
8
9
|
import { padTo } from "../utils/format";
|
|
9
10
|
|
|
@@ -17,6 +18,7 @@ export interface PriceSelectorDialogProps {
|
|
|
17
18
|
label: string;
|
|
18
19
|
currentValue?: number;
|
|
19
20
|
quote?: Quote;
|
|
21
|
+
assetCategory?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function buildPresets(quote: Quote | undefined): PricePreset[] {
|
|
@@ -46,20 +48,22 @@ function buildPresets(quote: Quote | undefined): PricePreset[] {
|
|
|
46
48
|
return presets;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
function formatMarketContext(quote: Quote | undefined): string {
|
|
51
|
+
function formatMarketContext(quote: Quote | undefined, assetCategory?: string): string {
|
|
50
52
|
if (!quote) return "No quote data available";
|
|
51
53
|
const parts: string[] = [];
|
|
52
|
-
parts.push(`Last ${quote.price
|
|
54
|
+
parts.push(`Last ${formatMarketPrice(quote.price, { assetCategory })}`);
|
|
53
55
|
if (quote.bid != null) {
|
|
54
|
-
const
|
|
56
|
+
const bidValue = formatMarketPrice(quote.bid, { assetCategory });
|
|
57
|
+
const bidStr = quote.bidSize != null ? `Bid ${bidValue} × ${quote.bidSize}` : `Bid ${bidValue}`;
|
|
55
58
|
parts.push(bidStr);
|
|
56
59
|
}
|
|
57
60
|
if (quote.ask != null) {
|
|
58
|
-
const
|
|
61
|
+
const askValue = formatMarketPrice(quote.ask, { assetCategory });
|
|
62
|
+
const askStr = quote.askSize != null ? `Ask ${askValue} × ${quote.askSize}` : `Ask ${askValue}`;
|
|
59
63
|
parts.push(askStr);
|
|
60
64
|
}
|
|
61
65
|
if (quote.bid != null && quote.ask != null) {
|
|
62
|
-
parts.push(`Spd ${(quote.ask - quote.bid)
|
|
66
|
+
parts.push(`Spd ${formatMarketPrice(quote.ask - quote.bid, { assetCategory })}`);
|
|
63
67
|
}
|
|
64
68
|
return parts.join(" · ");
|
|
65
69
|
}
|
|
@@ -72,6 +76,7 @@ export function PriceSelectorDialog({
|
|
|
72
76
|
label,
|
|
73
77
|
currentValue,
|
|
74
78
|
quote,
|
|
79
|
+
assetCategory,
|
|
75
80
|
}: PromptContext<string> & PriceSelectorDialogProps) {
|
|
76
81
|
const presets = buildPresets(quote);
|
|
77
82
|
const [index, setIndex] = useState(0);
|
|
@@ -135,7 +140,7 @@ export function PriceSelectorDialog({
|
|
|
135
140
|
}
|
|
136
141
|
>
|
|
137
142
|
<box flexDirection="column">
|
|
138
|
-
<text fg={colors.textDim}>{formatMarketContext(quote)}</text>
|
|
143
|
+
<text fg={colors.textDim}>{formatMarketContext(quote, assetCategory)}</text>
|
|
139
144
|
<box height={1} />
|
|
140
145
|
|
|
141
146
|
{presets.length > 0 && (
|
|
@@ -163,7 +168,7 @@ export function PriceSelectorDialog({
|
|
|
163
168
|
{padTo(item.label, presetWidth)}
|
|
164
169
|
</text>
|
|
165
170
|
<text fg={state.selected ? colors.text : colors.textMuted}>
|
|
166
|
-
{preset
|
|
171
|
+
{preset ? formatMarketPrice(preset.value, { assetCategory }) : ""}
|
|
167
172
|
</text>
|
|
168
173
|
{preset?.detail && (
|
|
169
174
|
<text fg={colors.textDim}>{` ${preset.detail}`}</text>
|
|
@@ -96,6 +96,56 @@ describe("TickerBadgeText", () => {
|
|
|
96
96
|
expect(opened).toEqual(["TSLA"]);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
test("renders inline links alongside ticker badges", async () => {
|
|
100
|
+
testSetup = await testRender(
|
|
101
|
+
<TickerBadgeText
|
|
102
|
+
text="Read https://example.com while watching $TSLA"
|
|
103
|
+
lineWidth={60}
|
|
104
|
+
catalog={{ TSLA: makeCatalogEntry() }}
|
|
105
|
+
textColor="#ffffff"
|
|
106
|
+
openTicker={() => {}}
|
|
107
|
+
/>,
|
|
108
|
+
{ width: 60, height: 4 },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await testSetup.renderOnce();
|
|
112
|
+
|
|
113
|
+
const frame = testSetup.captureCharFrame();
|
|
114
|
+
expect(frame).toContain("https://example.com");
|
|
115
|
+
expect(frame).toContain("TSLA -5%");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("opens detected links without trailing punctuation when clicked", async () => {
|
|
119
|
+
const opened: string[] = [];
|
|
120
|
+
testSetup = await testRender(
|
|
121
|
+
<TickerBadgeText
|
|
122
|
+
text="Read https://example.com/story."
|
|
123
|
+
lineWidth={60}
|
|
124
|
+
catalog={{}}
|
|
125
|
+
textColor="#ffffff"
|
|
126
|
+
openTicker={() => {}}
|
|
127
|
+
openLink={(url) => opened.push(url)}
|
|
128
|
+
/>,
|
|
129
|
+
{ width: 60, height: 4 },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await testSetup.renderOnce();
|
|
133
|
+
|
|
134
|
+
const lines = testSetup.captureCharFrame().split("\n");
|
|
135
|
+
const row = lines.findIndex((line) => line.includes("https://example.com/story."));
|
|
136
|
+
const col = lines[row]?.indexOf("https://example.com/story") ?? -1;
|
|
137
|
+
|
|
138
|
+
expect(row).toBeGreaterThanOrEqual(0);
|
|
139
|
+
expect(col).toBeGreaterThanOrEqual(0);
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
await testSetup!.mockMouse.click(col + 1, row);
|
|
143
|
+
await testSetup!.renderOnce();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(opened).toEqual(["https://example.com/story"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
99
149
|
test("shows the current price when the badge is hovered", async () => {
|
|
100
150
|
testSetup = await testRender(
|
|
101
151
|
<TickerBadgeText
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { TickerBadge } from "./ticker-badge";
|
|
3
|
-
import {
|
|
3
|
+
import { ExternalLinkText, openUrl } from "./ui";
|
|
4
|
+
import { tokenizeInlineContent } from "../utils/inline-content-tokenizer";
|
|
4
5
|
import type { InlineTickerCatalogEntry } from "../state/use-inline-tickers";
|
|
5
6
|
|
|
6
7
|
export interface TickerBadgeTextProps {
|
|
@@ -9,6 +10,7 @@ export interface TickerBadgeTextProps {
|
|
|
9
10
|
catalog: Record<string, InlineTickerCatalogEntry>;
|
|
10
11
|
textColor: string;
|
|
11
12
|
openTicker: (symbol: string) => void;
|
|
13
|
+
openLink?: (url: string) => void;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function TickerBadgeText({
|
|
@@ -17,9 +19,10 @@ export function TickerBadgeText({
|
|
|
17
19
|
catalog,
|
|
18
20
|
textColor,
|
|
19
21
|
openTicker,
|
|
22
|
+
openLink = openUrl,
|
|
20
23
|
}: TickerBadgeTextProps) {
|
|
21
24
|
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
22
|
-
const tokens =
|
|
25
|
+
const tokens = tokenizeInlineContent(text);
|
|
23
26
|
|
|
24
27
|
return (
|
|
25
28
|
<box
|
|
@@ -33,6 +36,18 @@ export function TickerBadgeText({
|
|
|
33
36
|
return <text key={`text:${index}`} fg={textColor}>{token.value}</text>;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
if (token.kind === "link") {
|
|
40
|
+
return (
|
|
41
|
+
<ExternalLinkText
|
|
42
|
+
key={`link:${index}`}
|
|
43
|
+
url={token.url}
|
|
44
|
+
label={token.value}
|
|
45
|
+
color={textColor}
|
|
46
|
+
onOpen={openLink}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
const entry = catalog[token.symbol];
|
|
37
52
|
if (!entry || entry.status === "missing") {
|
|
38
53
|
return <text key={`raw:${index}`} fg={textColor}>{token.value}</text>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core";
|
|
2
2
|
import { colors, priceColor } from "../theme/colors";
|
|
3
|
-
import {
|
|
3
|
+
import { formatMarketPriceWithCurrency } from "../utils/market-format";
|
|
4
4
|
import type { Quote } from "../types/financials";
|
|
5
5
|
|
|
6
6
|
export interface TickerBadgeProps {
|
|
@@ -50,7 +50,7 @@ export function TickerBadge({
|
|
|
50
50
|
}: TickerBadgeProps) {
|
|
51
51
|
const tone = status === "ready" && quote ? priceColor(quote.changePercent) : colors.borderFocused;
|
|
52
52
|
const label = hovered && quote
|
|
53
|
-
? `${symbol} ${
|
|
53
|
+
? `${symbol} ${formatMarketPriceWithCurrency(quote.price, quote.currency, { minimumFractionDigits: 2 })}`
|
|
54
54
|
: status === "ready" && quote
|
|
55
55
|
? formatBadgeChange(quote.changePercent)
|
|
56
56
|
: "…";
|