gloomberb 0.6.1 → 0.7.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/package.json +1 -1
- package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
- package/src/app.tsx +158 -57
- package/src/capabilities/factories.ts +3 -0
- package/src/cli/registry.test.ts +0 -1
- package/src/components/chart/chart-controller.ts +0 -1
- package/src/components/chart/chart-data.ts +1 -31
- package/src/components/chart/chart-renderer.ts +94 -12
- package/src/components/chart/chart-resolution.test.ts +0 -36
- package/src/components/chart/chart-resolution.ts +0 -27
- package/src/components/chart/chart.test.ts +60 -45
- package/src/components/chart/comparison-chart-data.test.ts +1 -5
- package/src/components/chart/comparison-chart-data.ts +1 -4
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +63 -39
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +30 -4
- package/src/components/chart/stock-chart.test.ts +0 -16
- package/src/components/chart/stock-chart.tsx +69 -41
- package/src/components/chart/time-axis-label.tsx +120 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +182 -36
- package/src/components/command-bar/command-bar.tsx +943 -1016
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/confirm-body.tsx +51 -0
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +76 -0
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -20
- package/src/components/command-bar/workflow-ops.ts +4 -0
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/data-table-stack-view.test.tsx +39 -4
- package/src/components/data-table-stack-view.tsx +11 -104
- package/src/components/data-table-view.tsx +43 -79
- package/src/components/feed-data-table-stack-view.tsx +10 -17
- package/src/components/feed-stack-controller.ts +39 -0
- package/src/components/index.ts +5 -1
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +35 -4
- package/src/components/layout/header.tsx +12 -7
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-cycle-order.test.ts +30 -0
- package/src/components/layout/pane-cycle-order.ts +32 -0
- package/src/components/layout/pane-footer.tsx +11 -4
- package/src/components/layout/pane-header.tsx +5 -5
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +264 -22
- package/src/components/layout/shell.tsx +46 -8
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1 -2
- package/src/components/pane-settings-dialog.tsx +12 -4
- package/src/components/pane-template-wizard.test.tsx +3 -4
- package/src/components/speedometer-gauge.test.tsx +45 -0
- package/src/components/speedometer-gauge.tsx +562 -0
- package/src/components/table-view-shared.tsx +218 -0
- package/src/components/ticker-list-table-view.tsx +57 -73
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/confirm-dialog.tsx +66 -0
- package/src/components/ui/data-table.tsx +30 -19
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/page-stack-view.tsx +39 -10
- package/src/components/ui/table-layout.test.ts +42 -0
- package/src/components/ui/table-layout.ts +49 -4
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +168 -21
- package/src/core/state/app-state.ts +10 -17
- package/src/market-data/coordinator.test.ts +76 -1
- package/src/market-data/coordinator.ts +218 -49
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -3
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
- package/src/plugins/builtin/ai/providers.ts +0 -17
- package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/ask-ai.test.tsx +89 -2
- package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
- package/src/plugins/builtin/broker-manager/index.tsx +19 -36
- package/src/plugins/builtin/chat-controller.test.ts +188 -1
- package/src/plugins/builtin/chat-controller.ts +537 -220
- package/src/plugins/builtin/chat.test.tsx +431 -13
- package/src/plugins/builtin/chat.tsx +1134 -227
- package/src/plugins/builtin/cloud-tweets.tsx +1 -1
- package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
- package/src/plugins/builtin/comparison-chart.tsx +11 -5
- package/src/plugins/builtin/correlation/index.tsx +1 -6
- package/src/plugins/builtin/debug.tsx +1 -3
- package/src/plugins/builtin/econ/index.tsx +1 -2
- package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
- package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
- package/src/plugins/builtin/fear-greed/index.tsx +587 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/help.tsx +2 -2
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +64 -29
- package/src/plugins/builtin/insider/index.tsx +22 -89
- package/src/plugins/builtin/market-movers/index.tsx +13 -19
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/index.tsx +41 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
- package/src/plugins/builtin/news.tsx +4 -16
- package/src/plugins/builtin/notes.tsx +19 -55
- package/src/plugins/builtin/options.test.tsx +58 -2
- package/src/plugins/builtin/options.tsx +110 -65
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +164 -33
- package/src/plugins/builtin/sec-filing-display.tsx +84 -0
- package/src/plugins/builtin/sec.tsx +13 -66
- package/src/plugins/builtin/sectors/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -1
- package/src/plugins/builtin/yield-curve/index.tsx +1 -1
- package/src/plugins/ibkr/broker-adapter.ts +1 -3
- package/src/plugins/ibkr/gateway-service-native.ts +1 -5
- package/src/plugins/ibkr/gateway-service.ts +1 -1
- package/src/plugins/ibkr/index.test.tsx +0 -2
- package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
- package/src/plugins/pane-manager.ts +0 -13
- package/src/plugins/plugin-runtime.test.tsx +40 -6
- package/src/plugins/plugin-runtime.tsx +20 -10
- package/src/plugins/prediction-markets/cli.ts +0 -1
- package/src/plugins/prediction-markets/controller-data.ts +0 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/prediction-markets/pane.test.tsx +3 -11
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
- package/src/plugins/registry.ts +13 -42
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +2 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +138 -101
- package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
- package/src/renderers/electrobun/view/input-host.tsx +74 -10
- package/src/renderers/electrobun/view/key-event.ts +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +329 -0
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/chart-surface.tsx +179 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +48 -30
- package/src/renderers/opentui/test-utils.tsx +17 -0
- package/src/renderers/opentui/ui-host.tsx +5 -2
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -15
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +65 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.test.tsx +73 -0
- package/src/state/use-inline-tickers.ts +19 -10
- package/src/test-support/plugin-runtime.ts +3 -2
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +76 -6
- package/src/theme/theme-context.tsx +26 -0
- package/src/theme/themes.test.ts +23 -1
- package/src/theme/themes.ts +115 -0
- package/src/types/broker.ts +1 -1
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -2
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -3
- package/src/ui/host.tsx +7 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/back-navigation.ts +24 -1
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
- package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
|
@@ -461,8 +461,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
|
|
|
461
461
|
const selectedResult = activeTab?.results.find((result) => result.symbol === cursorSymbol)
|
|
462
462
|
?? activeTab?.results[0]
|
|
463
463
|
?? null;
|
|
464
|
-
const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
|
|
465
|
-
const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
|
|
466
464
|
const isRunningActiveTab = runState?.tabId === activeTab?.id;
|
|
467
465
|
const forceRunArmed = !!activeTab && forceConfirmTabId === activeTab.id;
|
|
468
466
|
|
|
@@ -753,7 +751,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
|
|
|
753
751
|
return;
|
|
754
752
|
}
|
|
755
753
|
|
|
756
|
-
const key = event.name;
|
|
757
754
|
if (event.name === "t") {
|
|
758
755
|
addTab();
|
|
759
756
|
return;
|
|
@@ -762,11 +759,11 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
|
|
|
762
759
|
removeTab(activeTab.id);
|
|
763
760
|
return;
|
|
764
761
|
}
|
|
765
|
-
if (event.name === "["
|
|
762
|
+
if (event.name === "[") {
|
|
766
763
|
cycleTabs(-1);
|
|
767
764
|
return;
|
|
768
765
|
}
|
|
769
|
-
if (event.name === "]"
|
|
766
|
+
if (event.name === "]") {
|
|
770
767
|
cycleTabs(1);
|
|
771
768
|
return;
|
|
772
769
|
}
|
|
@@ -920,6 +917,7 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
|
|
|
920
917
|
variant="pill"
|
|
921
918
|
closeMode="active"
|
|
922
919
|
onAdd={editorState ? undefined : addTab}
|
|
920
|
+
focused={focused && !editorState}
|
|
923
921
|
/>
|
|
924
922
|
</Box>
|
|
925
923
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "../../../ui";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { TextAttributes } from "../../../ui";
|
|
4
|
-
import { useShortcut } from "../../../react/input";
|
|
5
4
|
import { DataTableView, Tabs, type DataTableColumn } from "../../../components";
|
|
6
5
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
7
6
|
import type { ColumnConfig } from "../../../types/config";
|
|
@@ -278,7 +277,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
|
|
|
278
277
|
() => portfolios.find((portfolio) => portfolio.id === activePortfolioId) ?? null,
|
|
279
278
|
[activePortfolioId, portfolios],
|
|
280
279
|
);
|
|
281
|
-
const activePortfolioIndex = portfolios.findIndex((portfolio) => portfolio.id === activePortfolioId);
|
|
282
280
|
const portfolioTabs = useMemo(
|
|
283
281
|
() => portfolios.map((portfolio) => ({ label: portfolio.name, value: portfolio.id })),
|
|
284
282
|
[portfolios],
|
|
@@ -555,22 +553,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
|
|
|
555
553
|
setSectorSort((current) => nextSectorSortPreference(current, columnId));
|
|
556
554
|
}, []);
|
|
557
555
|
|
|
558
|
-
useShortcut((event) => {
|
|
559
|
-
if (!focused) return;
|
|
560
|
-
|
|
561
|
-
const key = event.name;
|
|
562
|
-
if ((key === "h" || key === "left") && activePortfolioIndex > 0) {
|
|
563
|
-
const previousPortfolio = portfolios[activePortfolioIndex - 1];
|
|
564
|
-
if (previousPortfolio) handlePortfolioSelect(previousPortfolio.id);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
if ((key === "l" || key === "right") && activePortfolioIndex >= 0 && activePortfolioIndex < portfolios.length - 1) {
|
|
568
|
-
const nextPortfolio = portfolios[activePortfolioIndex + 1];
|
|
569
|
-
if (nextPortfolio) handlePortfolioSelect(nextPortfolio.id);
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
|
|
574
556
|
useEffect(() => {
|
|
575
557
|
if (activePortfolioId !== currentPortfolioId) {
|
|
576
558
|
setCurrentPortfolioId(activePortfolioId);
|
|
@@ -592,6 +574,7 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
|
|
|
592
574
|
activeValue={activePortfolioId}
|
|
593
575
|
onSelect={handlePortfolioSelect}
|
|
594
576
|
compact
|
|
577
|
+
focused={focused}
|
|
595
578
|
/>
|
|
596
579
|
</Box>
|
|
597
580
|
</Box>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import { act, useReducer } from "react";
|
|
3
3
|
import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
|
|
4
4
|
import { testRender } from "../../renderers/opentui/test-utils";
|
|
5
|
-
import { AppContext, PaneInstanceProvider, createInitialState } from "../../state/app-context";
|
|
5
|
+
import { AppContext, PaneInstanceProvider, appReducer, createInitialState } from "../../state/app-context";
|
|
6
6
|
import { createStatefulTestPluginRuntime } from "../../test-support/plugin-runtime";
|
|
7
7
|
import { createDefaultConfig } from "../../types/config";
|
|
8
8
|
import { Box } from "../../ui";
|
|
@@ -258,4 +258,91 @@ describe("AskAiTab", () => {
|
|
|
258
258
|
|
|
259
259
|
expect(opened).toEqual(["AAPL"]);
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
test("does not persist previous ticker messages while hydrating a newly selected ticker", async () => {
|
|
263
|
+
setProviders([
|
|
264
|
+
{ id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
const runtime = createStatefulTestPluginRuntime();
|
|
268
|
+
runtime.setResumeState("ai", "conversation:claude:AAPL", {
|
|
269
|
+
updatedAt: Date.now(),
|
|
270
|
+
messages: [
|
|
271
|
+
{ role: "user", content: "AAPL question" },
|
|
272
|
+
{ role: "assistant", content: "AAPL answer", loading: false },
|
|
273
|
+
],
|
|
274
|
+
}, 1);
|
|
275
|
+
runtime.setResumeState("ai", "conversation:claude:MSFT", {
|
|
276
|
+
updatedAt: Date.now(),
|
|
277
|
+
messages: [
|
|
278
|
+
{ role: "user", content: "MSFT question" },
|
|
279
|
+
{ role: "assistant", content: "MSFT answer", loading: false },
|
|
280
|
+
],
|
|
281
|
+
}, 1);
|
|
282
|
+
|
|
283
|
+
let selectSymbol: ((symbol: string) => void) | null = null;
|
|
284
|
+
|
|
285
|
+
const config = createDefaultConfig("/tmp/gloomberb-ai-navigation");
|
|
286
|
+
const initialState = createInitialState(config);
|
|
287
|
+
initialState.focusedPaneId = PANE_ID;
|
|
288
|
+
initialState.paneState["portfolio-list:main"] = {
|
|
289
|
+
...initialState.paneState["portfolio-list:main"],
|
|
290
|
+
cursorSymbol: "AAPL",
|
|
291
|
+
};
|
|
292
|
+
initialState.tickers = new Map([
|
|
293
|
+
["AAPL", makeTicker("AAPL", "Apple Inc.")],
|
|
294
|
+
["MSFT", makeTicker("MSFT", "Microsoft Corporation")],
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
function NavigableAskAiHarness() {
|
|
298
|
+
const [state, dispatch] = useReducer(appReducer, initialState);
|
|
299
|
+
selectSymbol = (symbol) => {
|
|
300
|
+
dispatch({
|
|
301
|
+
type: "UPDATE_PANE_STATE",
|
|
302
|
+
paneId: "portfolio-list:main",
|
|
303
|
+
patch: { cursorSymbol: symbol },
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<AppContext value={{ state, dispatch }}>
|
|
309
|
+
<PaneInstanceProvider paneId={PANE_ID}>
|
|
310
|
+
<PluginRenderProvider pluginId="ai" runtime={runtime}>
|
|
311
|
+
<PaneFooterProvider>
|
|
312
|
+
{(footer) => (
|
|
313
|
+
<Box flexDirection="column" width={60} height={12}>
|
|
314
|
+
<AskAiTab width={60} height={11} focused onCapture={() => {}} />
|
|
315
|
+
<PaneFooterBar footer={footer} focused width={60} />
|
|
316
|
+
</Box>
|
|
317
|
+
)}
|
|
318
|
+
</PaneFooterProvider>
|
|
319
|
+
</PluginRenderProvider>
|
|
320
|
+
</PaneInstanceProvider>
|
|
321
|
+
</AppContext>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await act(async () => {
|
|
326
|
+
testSetup = await testRender(<NavigableAskAiHarness />, {
|
|
327
|
+
width: 60,
|
|
328
|
+
height: 12,
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
await flushFrame();
|
|
332
|
+
|
|
333
|
+
expect(testSetup.captureCharFrame()).toContain("AAPL answer");
|
|
334
|
+
|
|
335
|
+
await act(() => {
|
|
336
|
+
selectSymbol?.("MSFT");
|
|
337
|
+
});
|
|
338
|
+
await flushFrame();
|
|
339
|
+
|
|
340
|
+
const frame = testSetup.captureCharFrame();
|
|
341
|
+
expect(frame).toContain("MSFT answer");
|
|
342
|
+
expect(frame).not.toContain("AAPL answer");
|
|
343
|
+
expect((runtime.getResumeState("ai", "conversation:claude:MSFT") as any)?.messages).toEqual([
|
|
344
|
+
{ role: "user", content: "MSFT question" },
|
|
345
|
+
{ role: "assistant", content: "MSFT answer", loading: false },
|
|
346
|
+
]);
|
|
347
|
+
});
|
|
261
348
|
});
|
|
@@ -65,7 +65,7 @@ function Harness({
|
|
|
65
65
|
const runtime = createTestPluginRuntime({
|
|
66
66
|
getBrokerAdapter: (brokerType) => brokerType === "ibkr" ? ibkrBroker : null,
|
|
67
67
|
openCommandBar: (query) => calls.push(`command:${query ?? ""}`),
|
|
68
|
-
|
|
68
|
+
showPane: (paneId) => calls.push(`pane:${paneId}`),
|
|
69
69
|
connectBrokerInstance: async (instanceId) => { calls.push(`connect:${instanceId}`); },
|
|
70
70
|
syncBrokerInstance: async (instanceId) => { calls.push(`sync:${instanceId}`); },
|
|
71
71
|
updateBrokerInstance: async (instanceId) => { calls.push(`update:${instanceId}`); },
|
|
@@ -163,6 +163,6 @@ describe("BrokersPane", () => {
|
|
|
163
163
|
await pressKey("s");
|
|
164
164
|
await pressKey("o");
|
|
165
165
|
|
|
166
|
-
expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "
|
|
166
|
+
expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "pane:ibkr-trading"]);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
@@ -2,6 +2,7 @@ import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
Button,
|
|
5
|
+
ConfirmDialog,
|
|
5
6
|
DataTableStackView,
|
|
6
7
|
NumberField,
|
|
7
8
|
SegmentedControl,
|
|
@@ -29,8 +30,7 @@ import type { BrokerAdapter, BrokerConfigField, BrokerProfileAction } from "../.
|
|
|
29
30
|
import type { BrokerInstanceConfig } from "../../../types/config";
|
|
30
31
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
31
32
|
import type { BrokerAccount } from "../../../types/trading";
|
|
32
|
-
import {
|
|
33
|
-
import { useDialog, useDialogKeyboard, type PromptContext } from "../../../ui/dialog";
|
|
33
|
+
import { useDialog, type PromptContext } from "../../../ui/dialog";
|
|
34
34
|
import { formatCurrency } from "../../../utils/format";
|
|
35
35
|
import { usePluginAppActions, usePluginBrokerActions } from "../../plugin-runtime";
|
|
36
36
|
import {
|
|
@@ -76,34 +76,6 @@ function isBrokerErrorMessage(message: string | null | undefined): boolean {
|
|
|
76
76
|
return normalized.includes("failed") || normalized.includes("required");
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function ConfirmRemoveBrokerDialog({
|
|
80
|
-
resolve,
|
|
81
|
-
row,
|
|
82
|
-
}: PromptContext<boolean> & { row: BrokerProfileRow }) {
|
|
83
|
-
const confirm = useCallback(() => resolve(true), [resolve]);
|
|
84
|
-
const cancel = useCallback(() => resolve(false), [resolve]);
|
|
85
|
-
|
|
86
|
-
useDialogKeyboard((event) => {
|
|
87
|
-
event.stopPropagation();
|
|
88
|
-
if (event.name === "enter" || event.name === "return") confirm();
|
|
89
|
-
if (event.name === "escape") cancel();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<DialogFrame title="Disconnect broker?" footer="Enter disconnect · Esc cancel">
|
|
94
|
-
<Box flexDirection="column" width={58}>
|
|
95
|
-
<Text fg={colors.text}>{`Remove "${row.label}" and imported broker data?`}</Text>
|
|
96
|
-
<Text fg={colors.textDim}>Broker-managed portfolios, positions, and contracts will be removed.</Text>
|
|
97
|
-
<Box height={1} />
|
|
98
|
-
<Box flexDirection="row" gap={1}>
|
|
99
|
-
<Button label="Disconnect" variant="danger" onPress={confirm} />
|
|
100
|
-
<Button label="Back" variant="secondary" onPress={cancel} />
|
|
101
|
-
</Box>
|
|
102
|
-
</Box>
|
|
103
|
-
</DialogFrame>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
79
|
function brokerFieldLabel(field: BrokerConfigField, focused: boolean): string {
|
|
108
80
|
return focused ? `> ${field.label}` : ` ${field.label}`;
|
|
109
81
|
}
|
|
@@ -202,7 +174,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
|
|
|
202
174
|
const config = useAppSelector((state) => state.config);
|
|
203
175
|
const brokerAccounts = useAppSelector((state) => state.brokerAccounts);
|
|
204
176
|
const dialog = useDialog();
|
|
205
|
-
const { openCommandBar,
|
|
177
|
+
const { openCommandBar, showPane } = usePluginAppActions();
|
|
206
178
|
const {
|
|
207
179
|
getBrokerAdapter,
|
|
208
180
|
connectBrokerInstance,
|
|
@@ -363,15 +335,26 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
|
|
|
363
335
|
setMessage(action.disabledReason ?? `${action.label} is unavailable for this profile.`);
|
|
364
336
|
return;
|
|
365
337
|
}
|
|
366
|
-
if (action.
|
|
367
|
-
}, [primaryProfileAction,
|
|
338
|
+
if (action.paneId) showPane(action.paneId);
|
|
339
|
+
}, [primaryProfileAction, showPane]);
|
|
368
340
|
|
|
369
341
|
const removeSelected = useCallback(async () => {
|
|
370
342
|
if (!selectedRow) return;
|
|
371
343
|
const confirmed = await dialog.prompt<boolean>({
|
|
372
344
|
closeOnClickOutside: true,
|
|
373
345
|
content: (ctx: PromptContext<boolean>) => (
|
|
374
|
-
<
|
|
346
|
+
<ConfirmDialog
|
|
347
|
+
{...ctx}
|
|
348
|
+
title="Disconnect broker?"
|
|
349
|
+
body={[
|
|
350
|
+
`Remove "${selectedRow.label}" and imported broker data?`,
|
|
351
|
+
"Broker-managed portfolios, positions, and contracts will be removed.",
|
|
352
|
+
]}
|
|
353
|
+
confirmLabel="Disconnect"
|
|
354
|
+
cancelLabel="Back"
|
|
355
|
+
width={58}
|
|
356
|
+
footer="Enter disconnect · Esc cancel"
|
|
357
|
+
/>
|
|
375
358
|
),
|
|
376
359
|
}).catch(() => false);
|
|
377
360
|
if (confirmed !== true) return;
|
|
@@ -392,7 +375,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
|
|
|
392
375
|
const hasSelectedRow = selectedRow !== null;
|
|
393
376
|
const selectedHasAdapter = !!selectedRow?.adapter;
|
|
394
377
|
const canUseSelectedBroker = selectedHasAdapter && !busy;
|
|
395
|
-
const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.
|
|
378
|
+
const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.paneId);
|
|
396
379
|
const canRemoveSelected = hasSelectedRow && !busy;
|
|
397
380
|
|
|
398
381
|
const footerActionsRef = useRef({
|
|
@@ -752,7 +735,7 @@ export const brokerManagerPlugin: GloomPlugin = {
|
|
|
752
735
|
keywords: ["broker", "brokers", "connection", "accounts", "sync"],
|
|
753
736
|
category: "navigation",
|
|
754
737
|
execute: () => {
|
|
755
|
-
ctx.
|
|
738
|
+
ctx.showPane("brokers");
|
|
756
739
|
},
|
|
757
740
|
});
|
|
758
741
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { AppNotificationRequest, PluginPersistence } from "../../types/plugin";
|
|
3
3
|
import type { PersistedResourceValue } from "../../types/persistence";
|
|
4
|
-
import { apiClient, type ChatMessage } from "../../utils/api-client";
|
|
4
|
+
import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
|
|
5
5
|
import { ChatController } from "./chat-controller";
|
|
6
6
|
|
|
7
7
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
@@ -11,6 +11,14 @@ const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
|
11
11
|
const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
|
|
12
12
|
const originalGetSession = apiClient.getSession.bind(apiClient);
|
|
13
13
|
const originalGetMessages = apiClient.getMessages.bind(apiClient);
|
|
14
|
+
const originalGetChannels = apiClient.getChannels.bind(apiClient);
|
|
15
|
+
|
|
16
|
+
const SERVER_CHAT_CHANNELS: ChatChannel[] = [
|
|
17
|
+
{ id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
|
|
18
|
+
{ id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
|
|
19
|
+
{ id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
|
|
20
|
+
{ id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" },
|
|
21
|
+
];
|
|
14
22
|
|
|
15
23
|
async function flushMicrotasks() {
|
|
16
24
|
await Promise.resolve();
|
|
@@ -83,12 +91,22 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
class TrackingPersistence extends MemoryPersistence {
|
|
95
|
+
stateWrites = 0;
|
|
96
|
+
|
|
97
|
+
setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
|
|
98
|
+
this.stateWrites += 1;
|
|
99
|
+
super.setState(key, value, options);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
afterEach(() => {
|
|
87
104
|
apiClient.dispose();
|
|
88
105
|
apiClient.setSessionToken(null);
|
|
89
106
|
apiClient.connectChannel = originalConnectChannel;
|
|
90
107
|
apiClient.getSession = originalGetSession;
|
|
91
108
|
apiClient.getMessages = originalGetMessages;
|
|
109
|
+
apiClient.getChannels = originalGetChannels;
|
|
92
110
|
});
|
|
93
111
|
|
|
94
112
|
describe("ChatController", () => {
|
|
@@ -132,6 +150,27 @@ describe("ChatController", () => {
|
|
|
132
150
|
expect(snapshot.messages.map((entry) => entry.id)).toEqual(["m1"]);
|
|
133
151
|
});
|
|
134
152
|
|
|
153
|
+
test("starts without client-seeded channels and hydrates the server channel list", async () => {
|
|
154
|
+
const controller = new ChatController();
|
|
155
|
+
apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
|
|
156
|
+
|
|
157
|
+
expect(controller.getSnapshot().channels).toEqual([]);
|
|
158
|
+
|
|
159
|
+
await controller.refreshChannels();
|
|
160
|
+
|
|
161
|
+
expect(controller.getSnapshot().channels).toEqual(SERVER_CHAT_CHANNELS);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("rejects unknown shortcut channels after the server list loads", async () => {
|
|
165
|
+
const controller = new ChatController();
|
|
166
|
+
apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
|
|
167
|
+
|
|
168
|
+
await expect(controller.resolveRequiredChannelId("help")).resolves.toBe("help");
|
|
169
|
+
await expect(controller.resolveRequiredChannelId("made-up")).rejects.toThrow(
|
|
170
|
+
'Unknown chat channel "#made-up".',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
135
174
|
test("hydrates a cached verified user into the api client for offline use", async () => {
|
|
136
175
|
const persistence = new MemoryPersistence();
|
|
137
176
|
const controller = new ChatController();
|
|
@@ -209,6 +248,34 @@ describe("ChatController", () => {
|
|
|
209
248
|
})).toBeNull();
|
|
210
249
|
});
|
|
211
250
|
|
|
251
|
+
test("defers draft persistence and subscriber sync until the user pauses or leaves", () => {
|
|
252
|
+
const persistence = new TrackingPersistence();
|
|
253
|
+
const controller = new ChatController();
|
|
254
|
+
const draftSnapshots: string[] = [];
|
|
255
|
+
|
|
256
|
+
controller.attachPersistence(persistence);
|
|
257
|
+
const unsubscribe = controller.subscribe((snapshot) => {
|
|
258
|
+
draftSnapshots.push(snapshot.draft);
|
|
259
|
+
});
|
|
260
|
+
draftSnapshots.length = 0;
|
|
261
|
+
persistence.stateWrites = 0;
|
|
262
|
+
|
|
263
|
+
controller.setDraft("h");
|
|
264
|
+
controller.setDraft("he");
|
|
265
|
+
|
|
266
|
+
expect(controller.getSnapshot().draft).toBe("he");
|
|
267
|
+
expect(draftSnapshots).toEqual([]);
|
|
268
|
+
expect(persistence.stateWrites).toBe(0);
|
|
269
|
+
expect(persistence.getState("channel:everyone", { schemaVersion: 1 })).toBeNull();
|
|
270
|
+
|
|
271
|
+
unsubscribe();
|
|
272
|
+
controller.dispose();
|
|
273
|
+
|
|
274
|
+
expect(persistence.getState<{ draft: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
275
|
+
draft: "he",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
212
279
|
test("pauses verification polling while the app is backgrounded", () => {
|
|
213
280
|
const controller = new ChatController();
|
|
214
281
|
|
|
@@ -254,6 +321,74 @@ describe("ChatController", () => {
|
|
|
254
321
|
expect((controller as any).wsConnection).toBeNull();
|
|
255
322
|
});
|
|
256
323
|
|
|
324
|
+
test("runs a quiet safety refresh while the live connection is active", async () => {
|
|
325
|
+
const persistence = new MemoryPersistence();
|
|
326
|
+
const controller = new ChatController();
|
|
327
|
+
const originalSetInterval = globalThis.setInterval;
|
|
328
|
+
const originalClearInterval = globalThis.clearInterval;
|
|
329
|
+
const intervalCallbacks: Array<() => void> = [];
|
|
330
|
+
const intervalHandle = { unref: () => {} };
|
|
331
|
+
let cleared = false;
|
|
332
|
+
let getMessagesCalls = 0;
|
|
333
|
+
const loadingSnapshots: boolean[] = [];
|
|
334
|
+
|
|
335
|
+
persistence.setState("session", {
|
|
336
|
+
sessionToken: "token-123",
|
|
337
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
338
|
+
}, { schemaVersion: 1 });
|
|
339
|
+
|
|
340
|
+
(globalThis as any).setInterval = (callback: () => void, timeout: number) => {
|
|
341
|
+
expect(timeout).toBe(30_000);
|
|
342
|
+
intervalCallbacks.push(callback);
|
|
343
|
+
return intervalHandle;
|
|
344
|
+
};
|
|
345
|
+
(globalThis as any).clearInterval = (handle: unknown) => {
|
|
346
|
+
if (handle === intervalHandle) {
|
|
347
|
+
cleared = true;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
apiClient.getMessages = async () => {
|
|
353
|
+
getMessagesCalls += 1;
|
|
354
|
+
return [];
|
|
355
|
+
};
|
|
356
|
+
apiClient.connectChannel = () => ({
|
|
357
|
+
send: async () => {
|
|
358
|
+
throw new Error("not implemented");
|
|
359
|
+
},
|
|
360
|
+
close: () => {},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
controller.attachPersistence(persistence);
|
|
364
|
+
controller.ensureConnection();
|
|
365
|
+
await flushMicrotasks();
|
|
366
|
+
|
|
367
|
+
expect(intervalCallbacks).toHaveLength(1);
|
|
368
|
+
expect(getMessagesCalls).toBe(1);
|
|
369
|
+
expect(controller.getSnapshot().loading).toBe(false);
|
|
370
|
+
|
|
371
|
+
controller.subscribe((snapshot) => {
|
|
372
|
+
loadingSnapshots.push(snapshot.loading);
|
|
373
|
+
});
|
|
374
|
+
loadingSnapshots.length = 0;
|
|
375
|
+
|
|
376
|
+
intervalCallbacks[0]!();
|
|
377
|
+
await flushMicrotasks();
|
|
378
|
+
|
|
379
|
+
expect(getMessagesCalls).toBe(2);
|
|
380
|
+
expect(loadingSnapshots).toEqual([false]);
|
|
381
|
+
|
|
382
|
+
controller.clearSession();
|
|
383
|
+
expect((controller as any).safetyRefreshTimer).toBeNull();
|
|
384
|
+
expect(cleared).toBe(true);
|
|
385
|
+
} finally {
|
|
386
|
+
controller.dispose();
|
|
387
|
+
(globalThis as any).setInterval = originalSetInterval;
|
|
388
|
+
(globalThis as any).clearInterval = originalClearInterval;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
257
392
|
test("keeps the cached session when session refresh fails transiently", async () => {
|
|
258
393
|
const persistence = new MemoryPersistence();
|
|
259
394
|
const controller = new ChatController();
|
|
@@ -307,6 +442,58 @@ describe("ChatController", () => {
|
|
|
307
442
|
});
|
|
308
443
|
});
|
|
309
444
|
|
|
445
|
+
test("keeps per-channel drafts and transcripts isolated", () => {
|
|
446
|
+
const persistence = new MemoryPersistence();
|
|
447
|
+
const controller = new ChatController();
|
|
448
|
+
const everyoneMessage: ChatMessage = {
|
|
449
|
+
id: "m1",
|
|
450
|
+
channelId: "everyone",
|
|
451
|
+
content: "general",
|
|
452
|
+
replyToId: null,
|
|
453
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
454
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
455
|
+
};
|
|
456
|
+
const optionsMessage: ChatMessage = {
|
|
457
|
+
id: "m2",
|
|
458
|
+
channelId: "options",
|
|
459
|
+
content: "options note",
|
|
460
|
+
replyToId: null,
|
|
461
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
462
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
persistence.setState("channel:everyone", {
|
|
466
|
+
draft: "general draft",
|
|
467
|
+
replyToId: null,
|
|
468
|
+
lastCursor: "m1",
|
|
469
|
+
lastViewedMessageId: "m1",
|
|
470
|
+
}, { schemaVersion: 1 });
|
|
471
|
+
persistence.setState("channel:options", {
|
|
472
|
+
draft: "options draft",
|
|
473
|
+
replyToId: null,
|
|
474
|
+
lastCursor: "m2",
|
|
475
|
+
lastViewedMessageId: "m2",
|
|
476
|
+
}, { schemaVersion: 1 });
|
|
477
|
+
persistence.setResource(TRANSCRIPT_KIND, "everyone", { messages: [everyoneMessage] }, {
|
|
478
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
479
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
480
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
481
|
+
});
|
|
482
|
+
persistence.setResource(TRANSCRIPT_KIND, "options", { messages: [optionsMessage] }, {
|
|
483
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
484
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
485
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
controller.attachPersistence(persistence);
|
|
489
|
+
controller.setChannelDraft("options", "updated options");
|
|
490
|
+
|
|
491
|
+
expect(controller.getSnapshot().messages.map((entry) => entry.content)).toEqual(["general"]);
|
|
492
|
+
expect(controller.getSnapshot().draft).toBe("general draft");
|
|
493
|
+
expect(controller.getSnapshot("options").messages.map((entry) => entry.content)).toEqual(["options note"]);
|
|
494
|
+
expect(controller.getSnapshot("options").draft).toBe("updated options");
|
|
495
|
+
});
|
|
496
|
+
|
|
310
497
|
test("stores the latest message id as the incremental cursor", async () => {
|
|
311
498
|
const persistence = new MemoryPersistence();
|
|
312
499
|
const controller = new ChatController();
|