gloomberb 0.7.1 → 0.7.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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +49 -15
- package/src/components/chart/chart-types.ts +2 -2
- package/src/components/chart/chart.test.ts +27 -8
- package/src/components/chart/comparison-stock-chart.tsx +84 -121
- package/src/components/chart/native/chart-rasterizer.ts +43 -17
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
- package/src/components/chart/stock-chart.tsx +59 -41
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +528 -383
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +1 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +47 -7
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +11 -51
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator.ts +8 -1
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +258 -44
- package/src/plugins/builtin/chat.test.tsx +257 -6
- package/src/plugins/builtin/chat.tsx +225 -60
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/news-wire/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +252 -41
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +67 -11
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/state/app-bootstrap.ts +3 -3
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/ui/host.tsx +8 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +128 -0
- package/src/utils/api-client.ts +112 -7
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
|
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import { resetTerminalInputState } from "../../utils/terminal-input-reset";
|
|
5
5
|
import type { KeyEventLike } from "../../react/input";
|
|
6
6
|
import type { NativeRendererHost, RendererHost } from "../../ui/host";
|
|
7
|
+
import { colors } from "../../theme/colors";
|
|
7
8
|
|
|
8
9
|
export { useKeyboard, useRenderer, useTerminalDimensions };
|
|
9
10
|
export type { CliRenderer };
|
|
@@ -54,7 +55,7 @@ export async function createOpenTuiHost(): Promise<OpenTuiHost> {
|
|
|
54
55
|
|
|
55
56
|
const renderer = await createCliRenderer({
|
|
56
57
|
exitOnCtrlC: true,
|
|
57
|
-
backgroundColor:
|
|
58
|
+
backgroundColor: colors.bg,
|
|
58
59
|
enableMouseMovement: true,
|
|
59
60
|
});
|
|
60
61
|
const root = createRoot(renderer);
|
|
@@ -66,7 +66,7 @@ export async function startOpenTuiApp(): Promise<void> {
|
|
|
66
66
|
<OpenTuiDialogHostProvider
|
|
67
67
|
size="medium"
|
|
68
68
|
dialogOptions={{ style: { backgroundColor: colors.bg, borderColor: colors.borderFocused, borderStyle: "single", paddingX: 2, paddingY: 1 } }}
|
|
69
|
-
backdropColor=
|
|
69
|
+
backdropColor={colors.bg}
|
|
70
70
|
backdropOpacity={0.8}
|
|
71
71
|
>
|
|
72
72
|
<App
|
|
@@ -4,6 +4,7 @@ import { testRender as openTuiTestRender } from "@opentui/react/test-utils";
|
|
|
4
4
|
import { createRoot as openTuiCreateRoot, useRenderer } from "@opentui/react";
|
|
5
5
|
import { UiHostProvider, type NativeRendererHost, type RendererHost } from "../../ui";
|
|
6
6
|
import { ToastHostProvider } from "../../ui/toast";
|
|
7
|
+
import { colors } from "../../theme/colors";
|
|
7
8
|
import { OpenTuiInputHostProvider } from "./input-host";
|
|
8
9
|
import { openTuiUiHost } from "./ui-host";
|
|
9
10
|
import { OpenTuiDialogHostProvider } from "./dialog-host";
|
|
@@ -14,8 +15,8 @@ export function TestDialogProvider({ children }: { children: ReactNode }) {
|
|
|
14
15
|
<DialogProvider
|
|
15
16
|
dialogOptions={{
|
|
16
17
|
style: {
|
|
17
|
-
backgroundColor:
|
|
18
|
-
borderColor:
|
|
18
|
+
backgroundColor: colors.bg,
|
|
19
|
+
borderColor: colors.borderFocused,
|
|
19
20
|
borderStyle: "single",
|
|
20
21
|
},
|
|
21
22
|
}}
|
|
@@ -54,9 +55,9 @@ export function OpenTuiTestProviders({ children }: { children: ReactNode }) {
|
|
|
54
55
|
<OpenTuiInputHostProvider>
|
|
55
56
|
<ToastHostProvider host={openTuiToastHost}>
|
|
56
57
|
<OpenTuiDialogHostProvider
|
|
57
|
-
backgroundColor=
|
|
58
|
-
containerBorderColor=
|
|
59
|
-
focusedBorderColor=
|
|
58
|
+
backgroundColor={colors.bg}
|
|
59
|
+
containerBorderColor={colors.border}
|
|
60
|
+
focusedBorderColor={colors.borderFocused}
|
|
60
61
|
>
|
|
61
62
|
{children}
|
|
62
63
|
</OpenTuiDialogHostProvider>
|
|
@@ -37,11 +37,11 @@ interface RefreshPlanEntry {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const MAX_BACKGROUND_WARMUP_TICKERS = 12;
|
|
40
|
-
const
|
|
41
|
-
"market_cap",
|
|
40
|
+
const PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS = new Set([
|
|
42
41
|
"pe",
|
|
43
42
|
"forward_pe",
|
|
44
43
|
"dividend_yield",
|
|
44
|
+
"sparkline",
|
|
45
45
|
]);
|
|
46
46
|
const startupLog = debugLog.createLogger("startup");
|
|
47
47
|
|
|
@@ -157,7 +157,7 @@ function buildRefreshPlan(
|
|
|
157
157
|
const columnIds = Array.isArray(instance.settings?.columnIds)
|
|
158
158
|
? instance.settings.columnIds.filter((value): value is string => typeof value === "string")
|
|
159
159
|
: [];
|
|
160
|
-
return columnIds.some((columnId) =>
|
|
160
|
+
return columnIds.some((columnId) => PORTFOLIO_FULL_SNAPSHOT_COLUMN_IDS.has(columnId))
|
|
161
161
|
? "financials"
|
|
162
162
|
: "quote";
|
|
163
163
|
};
|
|
@@ -329,6 +329,64 @@ describe("pane state updates", () => {
|
|
|
329
329
|
|
|
330
330
|
expect(next).toBe(initial);
|
|
331
331
|
});
|
|
332
|
+
|
|
333
|
+
test("keeps pane runtime state scoped to each saved layout", () => {
|
|
334
|
+
let state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
335
|
+
const originalLayoutIndex = state.config.activeLayoutIndex;
|
|
336
|
+
|
|
337
|
+
state = appReducer(state, {
|
|
338
|
+
type: "UPDATE_PANE_STATE",
|
|
339
|
+
paneId: "ticker-detail:main",
|
|
340
|
+
patch: { activeTabId: "chart" },
|
|
341
|
+
});
|
|
342
|
+
state = appReducer(state, { type: "DUPLICATE_LAYOUT", index: originalLayoutIndex });
|
|
343
|
+
const duplicateLayoutIndex = state.config.activeLayoutIndex;
|
|
344
|
+
|
|
345
|
+
state = appReducer(state, {
|
|
346
|
+
type: "UPDATE_PANE_STATE",
|
|
347
|
+
paneId: "ticker-detail:main",
|
|
348
|
+
patch: { activeTabId: "financials" },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(state.config.layouts[duplicateLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("financials");
|
|
352
|
+
|
|
353
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: originalLayoutIndex });
|
|
354
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
355
|
+
expect(state.config.layouts[originalLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
356
|
+
|
|
357
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: duplicateLayoutIndex });
|
|
358
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("financials");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("hydrates saved layout pane state before legacy session pane state", () => {
|
|
362
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
363
|
+
config.layouts[config.activeLayoutIndex] = {
|
|
364
|
+
...config.layouts[config.activeLayoutIndex]!,
|
|
365
|
+
paneState: {
|
|
366
|
+
"ticker-detail:main": { activeTabId: "chart" },
|
|
367
|
+
},
|
|
368
|
+
focusedPaneId: "ticker-detail:main",
|
|
369
|
+
activePanel: "right",
|
|
370
|
+
};
|
|
371
|
+
const sessionSnapshot: AppSessionSnapshot = {
|
|
372
|
+
paneState: {
|
|
373
|
+
"ticker-detail:main": { activeTabId: "overview" },
|
|
374
|
+
},
|
|
375
|
+
focusedPaneId: "portfolio-list:main",
|
|
376
|
+
activePanel: "left",
|
|
377
|
+
statusBarVisible: true,
|
|
378
|
+
openPaneIds: ["portfolio-list:main", "ticker-detail:main"],
|
|
379
|
+
hydrationTargets: [],
|
|
380
|
+
exchangeCurrencies: ["USD"],
|
|
381
|
+
savedAt: Date.now(),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const state = createInitialState(config, sessionSnapshot);
|
|
385
|
+
|
|
386
|
+
expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
|
|
387
|
+
expect(state.focusedPaneId).toBe("ticker-detail:main");
|
|
388
|
+
expect(state.activePanel).toBe("right");
|
|
389
|
+
});
|
|
332
390
|
});
|
|
333
391
|
|
|
334
392
|
describe("quote merging", () => {
|
|
@@ -487,6 +545,61 @@ describe("quote merging", () => {
|
|
|
487
545
|
});
|
|
488
546
|
|
|
489
547
|
describe("layout focus fallback", () => {
|
|
548
|
+
test("switching to an old layout without focused metadata uses that layout's top floating pane", () => {
|
|
549
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
550
|
+
const targetLayout = {
|
|
551
|
+
...cloneLayout(config.layout),
|
|
552
|
+
dockRoot: null,
|
|
553
|
+
floating: [
|
|
554
|
+
{ instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
|
|
555
|
+
{ instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
|
|
556
|
+
],
|
|
557
|
+
};
|
|
558
|
+
config.layouts = [
|
|
559
|
+
{ name: "Current", layout: cloneLayout(config.layout), paneState: {} },
|
|
560
|
+
{ name: "Floating", layout: cloneLayout(targetLayout), paneState: {} },
|
|
561
|
+
];
|
|
562
|
+
config.activeLayoutIndex = 0;
|
|
563
|
+
|
|
564
|
+
let state = createInitialState(config);
|
|
565
|
+
state = appReducer(state, { type: "FOCUS_PANE", paneId: "chat:main" });
|
|
566
|
+
state = appReducer(state, { type: "SWITCH_LAYOUT", index: 1 });
|
|
567
|
+
|
|
568
|
+
const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
|
|
569
|
+
const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
|
|
570
|
+
expect(state.focusedPaneId).toBe("ticker-detail:main");
|
|
571
|
+
expect((tickerEntry?.zIndex ?? 0) > (chatEntry?.zIndex ?? 0)).toBe(true);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("switching layouts raises the saved focused floating pane above stale z-order", () => {
|
|
575
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
576
|
+
const targetLayout = {
|
|
577
|
+
...cloneLayout(config.layout),
|
|
578
|
+
dockRoot: null,
|
|
579
|
+
floating: [
|
|
580
|
+
{ instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
|
|
581
|
+
{ instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
|
|
582
|
+
],
|
|
583
|
+
};
|
|
584
|
+
config.layouts = [
|
|
585
|
+
{ name: "Current", layout: cloneLayout(config.layout), paneState: {} },
|
|
586
|
+
{
|
|
587
|
+
name: "Floating",
|
|
588
|
+
layout: cloneLayout(targetLayout),
|
|
589
|
+
paneState: {},
|
|
590
|
+
focusedPaneId: "chat:main",
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
config.activeLayoutIndex = 0;
|
|
594
|
+
|
|
595
|
+
const state = appReducer(createInitialState(config), { type: "SWITCH_LAYOUT", index: 1 });
|
|
596
|
+
|
|
597
|
+
const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
|
|
598
|
+
const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
|
|
599
|
+
expect(state.focusedPaneId).toBe("chat:main");
|
|
600
|
+
expect((chatEntry?.zIndex ?? 0) > (tickerEntry?.zIndex ?? 0)).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
490
603
|
test("starts with the top floating pane focused when no session pane is active", () => {
|
|
491
604
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
492
605
|
const layout = {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type PaneRuntimeState,
|
|
38
38
|
resolveCollectionForPane,
|
|
39
39
|
resolveTickerForPane,
|
|
40
|
+
syncConfigActiveLayoutState,
|
|
40
41
|
} from "../core/state/app-state";
|
|
41
42
|
import type { AppAction, AppState } from "../core/state/app-state";
|
|
42
43
|
import { scheduleConfigSave } from "./config-save-scheduler";
|
|
@@ -50,6 +51,7 @@ export {
|
|
|
50
51
|
getPaneState,
|
|
51
52
|
resolveCollectionForPane,
|
|
52
53
|
resolveFinancialsForPane,
|
|
54
|
+
syncConfigActiveLayoutState,
|
|
53
55
|
resolveTickerFileForPane,
|
|
54
56
|
resolveTickerForPane,
|
|
55
57
|
} from "../core/state/app-state";
|
|
@@ -283,10 +285,12 @@ export function usePaneSettingValue<T>(
|
|
|
283
285
|
? (nextValue as (previousValue: T) => T)(currentValue)
|
|
284
286
|
: nextValue;
|
|
285
287
|
const layout = setPaneSetting(currentState.config.layout, scopedPaneId, key, resolved);
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
288
|
+
const nextConfig = syncConfigActiveLayoutState(
|
|
289
|
+
{ ...currentState.config, layout },
|
|
290
|
+
currentState.paneState,
|
|
291
|
+
currentState.focusedPaneId,
|
|
292
|
+
currentState.activePanel,
|
|
293
|
+
);
|
|
290
294
|
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
291
295
|
scheduleConfigSave(nextConfig);
|
|
292
296
|
}, [dispatch, fallback, key, scopedPaneId, stateRef]);
|
package/src/theme/colors.ts
CHANGED
|
@@ -73,21 +73,20 @@ export function syncTheme(id: string): void {
|
|
|
73
73
|
|
|
74
74
|
export { blendHex } from "./color-utils";
|
|
75
75
|
|
|
76
|
-
const COMPARISON_SERIES_COLORS = [
|
|
77
|
-
"#5bc0eb",
|
|
78
|
-
"#f6ae2d",
|
|
79
|
-
"#f26419",
|
|
80
|
-
"#6f2dbd",
|
|
81
|
-
"#00a896",
|
|
82
|
-
"#ef476f",
|
|
83
|
-
"#118ab2",
|
|
84
|
-
"#8ac926",
|
|
85
|
-
"#ff7f51",
|
|
86
|
-
"#4361ee",
|
|
87
|
-
] as const;
|
|
88
|
-
|
|
89
76
|
export function getComparisonSeriesColor(index: number): string {
|
|
90
|
-
|
|
77
|
+
const seriesColors = [
|
|
78
|
+
colors.borderFocused,
|
|
79
|
+
colors.warning,
|
|
80
|
+
colors.positive,
|
|
81
|
+
colors.negative,
|
|
82
|
+
colors.textBright,
|
|
83
|
+
colors.neutral,
|
|
84
|
+
blendHex(colors.borderFocused, colors.positive, 0.45),
|
|
85
|
+
blendHex(colors.warning, colors.negative, 0.35),
|
|
86
|
+
blendHex(colors.textBright, colors.positive, 0.35),
|
|
87
|
+
blendHex(colors.borderFocused, colors.negative, 0.35),
|
|
88
|
+
];
|
|
89
|
+
return seriesColors[((index % seriesColors.length) + seriesColors.length) % seriesColors.length]!;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/** Returns a hover background color derived from bg and selected */
|
package/src/types/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Portfolio, Watchlist } from "./ticker";
|
|
2
2
|
|
|
3
|
-
export const CURRENT_CONFIG_VERSION =
|
|
3
|
+
export const CURRENT_CONFIG_VERSION = 16;
|
|
4
4
|
|
|
5
|
-
export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
|
|
5
|
+
export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc" | "hlc";
|
|
6
6
|
export type ChartRendererPreference = "auto" | "kitty" | "braille";
|
|
7
7
|
|
|
8
8
|
export interface ChartPreferences {
|
|
@@ -108,8 +108,12 @@ export interface LayoutConfig {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export interface SavedLayout {
|
|
111
|
+
id?: string;
|
|
111
112
|
name: string;
|
|
112
113
|
layout: LayoutConfig;
|
|
114
|
+
paneState?: Record<string, Record<string, unknown>>;
|
|
115
|
+
focusedPaneId?: string | null;
|
|
116
|
+
activePanel?: "left" | "right";
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
export interface AppConfig {
|
|
@@ -154,6 +158,7 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
|
|
|
154
158
|
|
|
155
159
|
export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
|
|
156
160
|
...DEFAULT_COLUMNS.map((column) => column.id),
|
|
161
|
+
"sparkline",
|
|
157
162
|
"shares",
|
|
158
163
|
"avg_cost",
|
|
159
164
|
"cost_basis",
|
|
@@ -585,8 +590,8 @@ export function createDefaultConfig(dataDir: string): AppConfig {
|
|
|
585
590
|
watchlists: [{ id: "watchlist", name: "Watchlist" }],
|
|
586
591
|
layout,
|
|
587
592
|
layouts: [
|
|
588
|
-
{ name: "Home", layout: cloneLayout(layout) },
|
|
589
|
-
{ name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT) },
|
|
593
|
+
{ name: "Home", layout: cloneLayout(layout), paneState: {} },
|
|
594
|
+
{ name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT), paneState: {} },
|
|
590
595
|
],
|
|
591
596
|
activeLayoutIndex: 0,
|
|
592
597
|
brokerInstances: [],
|
package/src/types/plugin.ts
CHANGED
|
@@ -110,6 +110,7 @@ export type PaneSettingField =
|
|
|
110
110
|
|
|
111
111
|
export interface PaneSettingsDef {
|
|
112
112
|
title?: string;
|
|
113
|
+
values?: Record<string, unknown>;
|
|
113
114
|
fields: PaneSettingField[];
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -406,6 +407,12 @@ export interface BrokerInstanceUpdateOptions {
|
|
|
406
407
|
replaceConfig?: boolean;
|
|
407
408
|
}
|
|
408
409
|
|
|
410
|
+
export interface PinTickerOptions {
|
|
411
|
+
floating?: boolean;
|
|
412
|
+
paneType?: string;
|
|
413
|
+
forceNewPane?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
409
416
|
export interface GloomPluginContext {
|
|
410
417
|
registerPane(pane: PaneDef): void;
|
|
411
418
|
registerPaneTemplate(template: PaneTemplateDef): void;
|
|
@@ -448,7 +455,7 @@ export interface GloomPluginContext {
|
|
|
448
455
|
createPaneFromTemplate(templateId: string, options?: PaneTemplateCreateOptions): void;
|
|
449
456
|
hidePane(paneId: string): void;
|
|
450
457
|
focusPane(paneId: string): void;
|
|
451
|
-
pinTicker(symbol: string, options?:
|
|
458
|
+
pinTicker(symbol: string, options?: PinTickerOptions): void;
|
|
452
459
|
navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
|
|
453
460
|
openPaneSettings(paneId?: string): void;
|
|
454
461
|
|
package/src/ui/context-menu.tsx
CHANGED
|
@@ -127,7 +127,7 @@ function selectedTextMenuItems(text: string, registry: PluginRegistry | null, co
|
|
|
127
127
|
contextMenuDivider("selected-text:ticker-divider"),
|
|
128
128
|
{
|
|
129
129
|
id: "selected-text:open-ticker",
|
|
130
|
-
label: "Open
|
|
130
|
+
label: "Open Ticker Detail",
|
|
131
131
|
onSelect: () => registry?.navigateTicker(symbol),
|
|
132
132
|
},
|
|
133
133
|
{
|
|
@@ -224,13 +224,13 @@ export function tickerContextMenuItems({
|
|
|
224
224
|
const items: ContextMenuItem[] = [
|
|
225
225
|
{
|
|
226
226
|
id: "ticker:open",
|
|
227
|
-
label: `Open ${symbol}`,
|
|
227
|
+
label: `Open ${symbol} Detail`,
|
|
228
228
|
onSelect: () => (openTicker ? openTicker(symbol) : registry?.navigateTicker(symbol)),
|
|
229
229
|
},
|
|
230
230
|
{
|
|
231
231
|
id: "ticker:pin-floating",
|
|
232
|
-
label: `
|
|
233
|
-
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" }),
|
|
232
|
+
label: `Open ${symbol} in New Floating Detail`,
|
|
233
|
+
onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail", forceNewPane: true }),
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
id: "ticker:copy-symbol",
|
package/src/ui/host.tsx
CHANGED
|
@@ -94,6 +94,12 @@ export interface BoxRenderable {
|
|
|
94
94
|
[key: string]: unknown;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
export interface ScrollBarRenderable {
|
|
98
|
+
visible: boolean;
|
|
99
|
+
on?(event: "change", handler: () => void): void;
|
|
100
|
+
off?(event: "change", handler: () => void): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
export interface ScrollBoxRenderable {
|
|
98
104
|
scrollTop: number;
|
|
99
105
|
scrollLeft?: number;
|
|
@@ -107,8 +113,8 @@ export interface ScrollBoxRenderable {
|
|
|
107
113
|
visible?: boolean;
|
|
108
114
|
parent?: unknown;
|
|
109
115
|
getBoundingClientRect?: () => { x: number; y: number; width: number; height: number };
|
|
110
|
-
horizontalScrollBar?:
|
|
111
|
-
verticalScrollBar?:
|
|
116
|
+
horizontalScrollBar?: ScrollBarRenderable;
|
|
117
|
+
verticalScrollBar?: ScrollBarRenderable;
|
|
112
118
|
scrollTo(target: number | { x?: number; y?: number }, y?: number): void;
|
|
113
119
|
scrollToPixels?(target: number | { x?: number; y?: number }, y?: number): void;
|
|
114
120
|
}
|
package/src/ui/index.tsx
CHANGED
|
@@ -282,6 +282,134 @@ describe("apiClient chat timestamps", () => {
|
|
|
282
282
|
expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
|
|
283
283
|
channel.close();
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
test("fetches chat state and normalizes pending notification timestamps", async () => {
|
|
287
|
+
let requestedUrl = "";
|
|
288
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
|
|
289
|
+
requestedUrl = String(input);
|
|
290
|
+
return createResponse({
|
|
291
|
+
channels: [{ id: "everyone", name: "everyone", created_at: "2026-04-08T07:00:00.000Z" }],
|
|
292
|
+
onlineCount: 2,
|
|
293
|
+
channelStates: [{
|
|
294
|
+
channelId: "everyone",
|
|
295
|
+
notificationsEnabled: true,
|
|
296
|
+
lastReadMessageId: "m1",
|
|
297
|
+
unreadCount: 1,
|
|
298
|
+
}],
|
|
299
|
+
notifications: [{
|
|
300
|
+
id: "n1",
|
|
301
|
+
type: "reply",
|
|
302
|
+
channelId: "everyone",
|
|
303
|
+
messageId: "m2",
|
|
304
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
305
|
+
message: {
|
|
306
|
+
id: "m2",
|
|
307
|
+
channelId: "everyone",
|
|
308
|
+
content: "reply",
|
|
309
|
+
replyToId: "m1",
|
|
310
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
311
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
312
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
313
|
+
},
|
|
314
|
+
}],
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const state = await apiClient.getChatState();
|
|
319
|
+
|
|
320
|
+
expect(new URL(requestedUrl).pathname).toBe("/chat/state");
|
|
321
|
+
expect(state.onlineCount).toBe(2);
|
|
322
|
+
expect(state.notifications[0]?.createdAt).toBe("2026-04-08T07:30:00.000Z");
|
|
323
|
+
expect(state.notifications[0]?.message.createdAt).toBe("2026-04-08T07:29:00.000Z");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("updates chat channel state and marks notifications delivered", async () => {
|
|
327
|
+
const requests: Array<{ path: string; method: string; body: unknown }> = [];
|
|
328
|
+
const responses = [
|
|
329
|
+
createResponse({
|
|
330
|
+
channelId: "everyone",
|
|
331
|
+
notificationsEnabled: true,
|
|
332
|
+
lastReadMessageId: "m2",
|
|
333
|
+
unreadCount: 0,
|
|
334
|
+
}),
|
|
335
|
+
createResponse({ delivered: 2 }),
|
|
336
|
+
createResponse({ onlineCount: 4 }),
|
|
337
|
+
];
|
|
338
|
+
globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
|
|
339
|
+
requests.push({
|
|
340
|
+
path: new URL(String(input)).pathname,
|
|
341
|
+
method: init?.method ?? "GET",
|
|
342
|
+
body: init?.body ? JSON.parse(String(init.body)) : null,
|
|
343
|
+
});
|
|
344
|
+
return responses.shift() as Response;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await apiClient.updateChatChannelState("everyone", {
|
|
348
|
+
notificationsEnabled: true,
|
|
349
|
+
readThroughMessageId: "m2",
|
|
350
|
+
});
|
|
351
|
+
await apiClient.markChatNotificationsDelivered(["n1", "n2"]);
|
|
352
|
+
const presence = await apiClient.getChatPresence();
|
|
353
|
+
|
|
354
|
+
expect(requests).toEqual([
|
|
355
|
+
{
|
|
356
|
+
path: "/chat/channels/everyone/state",
|
|
357
|
+
method: "PATCH",
|
|
358
|
+
body: { notificationsEnabled: true, readThroughMessageId: "m2" },
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
path: "/chat/notifications/delivered",
|
|
362
|
+
method: "POST",
|
|
363
|
+
body: { notificationIds: ["n1", "n2"] },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
path: "/chat/presence",
|
|
367
|
+
method: "GET",
|
|
368
|
+
body: null,
|
|
369
|
+
},
|
|
370
|
+
]);
|
|
371
|
+
expect(presence).toEqual({ onlineCount: 4 });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("emits websocket chat presence and notification events", async () => {
|
|
375
|
+
const seenPresence: number[] = [];
|
|
376
|
+
const seenNotifications: string[] = [];
|
|
377
|
+
const unsubscribePresence = apiClient.subscribeChatPresence((onlineCount) => {
|
|
378
|
+
seenPresence.push(onlineCount);
|
|
379
|
+
});
|
|
380
|
+
const unsubscribeNotifications = apiClient.subscribeChatNotifications((notification) => {
|
|
381
|
+
seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
385
|
+
type: "chat.presence",
|
|
386
|
+
onlineCount: 5,
|
|
387
|
+
}));
|
|
388
|
+
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
389
|
+
type: "chat.notification",
|
|
390
|
+
data: {
|
|
391
|
+
id: "n1",
|
|
392
|
+
type: "reply",
|
|
393
|
+
channelId: "everyone",
|
|
394
|
+
messageId: "m2",
|
|
395
|
+
createdAt: "2026-04-08 07:30:00.000",
|
|
396
|
+
message: {
|
|
397
|
+
id: "m2",
|
|
398
|
+
channelId: "everyone",
|
|
399
|
+
content: "reply",
|
|
400
|
+
replyToId: "m1",
|
|
401
|
+
createdAt: "2026-04-08 07:29:00.000",
|
|
402
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
403
|
+
replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
|
|
408
|
+
expect(seenPresence).toEqual([5]);
|
|
409
|
+
expect(seenNotifications).toEqual(["n1:2026-04-08T07:29:00.000Z"]);
|
|
410
|
+
unsubscribePresence();
|
|
411
|
+
unsubscribeNotifications();
|
|
412
|
+
});
|
|
285
413
|
});
|
|
286
414
|
|
|
287
415
|
describe("apiClient cloud news", () => {
|