gloomberb 0.9.5 → 0.9.7
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 +49 -8
- package/package.json +2 -2
- package/src/app/global-shortcuts.test.tsx +20 -1
- package/src/app/global-shortcuts.ts +2 -0
- package/src/app/pane-runtime/index.tsx +11 -4
- package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
- package/src/app.tsx +41 -17
- package/src/cli/commands/remote.ts +206 -0
- package/src/cli/index.ts +2 -0
- package/src/components/chart/core/keyboard.ts +10 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
- package/src/components/chart/static/chart/surface.test.tsx +59 -4
- package/src/components/chart/static/chart/surface.tsx +4 -0
- package/src/components/chart/stock-chart/index.test.ts +7 -0
- package/src/components/command-bar/list/view.tsx +47 -0
- package/src/components/command-bar/multi-select-picker.tsx +7 -0
- package/src/components/command-bar/routing/navigation-state.ts +10 -1
- package/src/components/command-bar/surface/index.test.tsx +14 -0
- package/src/components/command-bar/theme-picker.tsx +91 -79
- package/src/components/command-bar/workflow/field-row.tsx +30 -5
- package/src/components/index.ts +7 -0
- package/src/components/layout/pane/footer/index.tsx +23 -0
- package/src/components/layout/shell/index.test.tsx +2 -0
- package/src/components/layout/shell/index.tsx +8 -3
- package/src/components/layout/shell/pane/actions.ts +21 -7
- package/src/components/metric-treemap/index.tsx +317 -0
- package/src/components/metric-treemap/layout.test.ts +77 -0
- package/src/components/metric-treemap/layout.ts +476 -0
- package/src/components/toggle-list.tsx +10 -0
- package/src/components/ui/button.tsx +12 -0
- package/src/components/ui/data-table/index.tsx +51 -0
- package/src/components/ui/fields.tsx +18 -0
- package/src/components/ui/list-view.tsx +83 -3
- package/src/components/ui/message-composer.tsx +22 -0
- package/src/components/ui/tabs.tsx +45 -0
- package/src/core/state/app/layout-reducer.ts +7 -1
- package/src/core/state/app/layout.ts +6 -0
- package/src/core/state/app/state.ts +23 -1
- package/src/core/state/app/types.ts +4 -2
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/chat/channels.ts +5 -18
- package/src/plugins/builtin/chat/chat.test.tsx +1 -7
- package/src/plugins/builtin/chat/status-widget.tsx +1 -7
- package/src/plugins/builtin/cloud/plugin.tsx +4 -13
- package/src/plugins/builtin/holders/treemap.tsx +28 -297
- package/src/plugins/builtin/holders/types.ts +0 -34
- package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
- package/src/plugins/builtin/market-heatmap/data.ts +296 -0
- package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
- package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
- package/src/plugins/ibkr/gateway/service/index.ts +6 -9
- package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
- package/src/remote/app-host.tsx +77 -0
- package/src/remote/client.ts +34 -0
- package/src/remote/command-bar.ts +98 -0
- package/src/remote/controller-utils.ts +111 -0
- package/src/remote/controller.test.ts +365 -0
- package/src/remote/controller.ts +434 -0
- package/src/remote/endpoint.ts +41 -0
- package/src/remote/json-patch.test.ts +31 -0
- package/src/remote/json-patch.ts +89 -0
- package/src/remote/layout-helpers.ts +62 -0
- package/src/remote/resources.ts +186 -0
- package/src/remote/revision.ts +18 -0
- package/src/remote/schema.ts +143 -0
- package/src/remote/semantic-helpers.ts +161 -0
- package/src/remote/semantic-tree.tsx +112 -0
- package/src/remote/server.test.ts +50 -0
- package/src/remote/server.ts +121 -0
- package/src/remote/types.ts +119 -0
- package/src/renderers/electrobun/bun/index.ts +55 -1
- package/src/renderers/electrobun/shared/protocol.ts +11 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
- package/src/renderers/electrobun/view/build-assets.ts +1 -0
- package/src/renderers/electrobun/view/host/box.tsx +5 -0
- package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
- package/src/renderers/electrobun/view/host/mouse.ts +2 -0
- package/src/renderers/electrobun/view/main.tsx +5 -1
- package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
- package/src/renderers/opentui/chart-surface.test.tsx +101 -0
- package/src/renderers/opentui/chart-surface.tsx +15 -20
- package/src/renderers/opentui/start.tsx +32 -0
- package/src/sources/yahoo-finance/http.ts +31 -4
- package/src/state/app/context/index.test.tsx +47 -0
- package/src/state/app/context/index.tsx +48 -18
- package/src/types/config.ts +1 -0
- package/src/ui/host.tsx +1 -0
- package/src/ui/index.tsx +183 -7
- package/src/version.ts +1 -1
- package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
package/README.md
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# Gloomberb
|
|
6
6
|
|
|
7
|
-
**Open-source finance terminal. Fast, keyboard-driven and extensible.**
|
|
7
|
+
**Open-source finance terminal. Fast, keyboard-driven, and extensible.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Desktop app for macOS and Windows. Terminal UI for macOS, Linux, and Windows.
|
|
10
10
|
|
|
11
|
-
<a href="https://gloomberb.com/download/desktop"><strong>Download desktop
|
|
11
|
+
<a href="https://gloomberb.com/download/desktop"><strong>Download desktop</strong></a>
|
|
12
|
+
·
|
|
13
|
+
<a href="#install"><strong>Install the TUI</strong></a>
|
|
12
14
|
|
|
13
15
|
<br />
|
|
14
16
|
<br />
|
|
@@ -17,9 +19,22 @@ Available as a desktop app or TUI.
|
|
|
17
19
|
|
|
18
20
|
</div>
|
|
19
21
|
|
|
22
|
+
## Desktop App or TUI?
|
|
23
|
+
|
|
24
|
+
Gloomberb has two ways in:
|
|
25
|
+
|
|
26
|
+
| Surface | Best for | How it runs |
|
|
27
|
+
|---------|----------|-------------|
|
|
28
|
+
| Desktop app | A polished app window, pop-out panes, OS shortcuts, and built-in updates | Published for macOS and Windows. It also installs a `gloomberb` terminal command for the TUI. |
|
|
29
|
+
| Terminal UI | Fast keyboard workflows inside your terminal, SSH/dev boxes, Linux machines, and script-friendly setups | Runs with `gloomberb` on macOS, Linux, and Windows. |
|
|
30
|
+
|
|
31
|
+
Both share the same command language, plugin system, market data surfaces, portfolios, watchlists, alerts, notes, and AI tools.
|
|
32
|
+
|
|
20
33
|
## Install
|
|
21
34
|
|
|
22
|
-
macOS
|
|
35
|
+
### macOS
|
|
36
|
+
|
|
37
|
+
Install the desktop app and the `gloomberb` terminal command:
|
|
23
38
|
|
|
24
39
|
```bash
|
|
25
40
|
brew install --cask vincelwt/tap/gloomberb
|
|
@@ -27,13 +42,37 @@ brew install --cask vincelwt/tap/gloomberb
|
|
|
27
42
|
curl -fsSL gloomberb.com/install | bash
|
|
28
43
|
```
|
|
29
44
|
|
|
30
|
-
Both install `Gloomberb.app` and a `gloomberb`
|
|
45
|
+
Both install `Gloomberb.app` and a `gloomberb` command that runs the TUI through the app bundle, so the bundled runtime is stored once.
|
|
31
46
|
|
|
32
|
-
|
|
47
|
+
Prefer a direct download?
|
|
33
48
|
|
|
34
49
|
- [Download Gloomberb for Mac](https://gloomberb.com/download/desktop)
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
### Linux
|
|
52
|
+
|
|
53
|
+
Install the standalone TUI binary:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -fsSL gloomberb.com/install | bash
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This installs `gloomberb` to `~/.local/bin` by default. A Linux desktop package is not published yet.
|
|
60
|
+
|
|
61
|
+
### Windows
|
|
62
|
+
|
|
63
|
+
Install the desktop app:
|
|
64
|
+
|
|
65
|
+
- [Download GloomberbSetup.exe for Windows x64](https://github.com/vincelwt/gloomberb/releases/latest/download/stable-win-x64-GloomberbSetup.exe)
|
|
66
|
+
|
|
67
|
+
The installer adds the app and a `gloomberb` terminal command. For a terminal-only setup, install Bun and use the package:
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
bun install -g gloomberb
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Terminal Package
|
|
74
|
+
|
|
75
|
+
Already have Bun installed on any supported OS?
|
|
37
76
|
|
|
38
77
|
```bash
|
|
39
78
|
bun install -g gloomberb
|
|
@@ -45,7 +84,7 @@ Then run:
|
|
|
45
84
|
gloomberb
|
|
46
85
|
```
|
|
47
86
|
|
|
48
|
-
On macOS,
|
|
87
|
+
On macOS and Windows, desktop updates replace the installed app in place and keep the terminal command pointing at the updated runtime. Homebrew users can also update through `brew upgrade --cask gloomberb`.
|
|
49
88
|
|
|
50
89
|
For the best terminal experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminal such as Ghostty, Kitty, or WezTerm.
|
|
51
90
|
|
|
@@ -58,6 +97,7 @@ Open command mode with `Ctrl+P`, then type a command. Press `` ` `` to open tick
|
|
|
58
97
|
| `DES AAPL` | Security details |
|
|
59
98
|
| `GP NVDA` | Price chart |
|
|
60
99
|
| `TOP` | Ranked market stories |
|
|
100
|
+
| `HM` | Market heatmap |
|
|
61
101
|
| `MOST` | Market movers |
|
|
62
102
|
| `PF` | Portfolios and watchlists |
|
|
63
103
|
| `KELLY AAPL` | Position sizing |
|
|
@@ -177,6 +217,7 @@ Use `HELP` inside Gloomberb for the live shortcut list. The common command-bar p
|
|
|
177
217
|
| Shortcut | Function |
|
|
178
218
|
|----------|----------|
|
|
179
219
|
| `TOP` | Ranked market stories |
|
|
220
|
+
| `HM` | Market heatmap for large US stocks and ETFs |
|
|
180
221
|
| `MOST` | Top gainers, losers, most active, and trending tickers |
|
|
181
222
|
| `PM <query>` | Polymarket and Kalshi prediction data |
|
|
182
223
|
| `N` | News feed |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gloomberb",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Market research and portfolio tracker for the terminal",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"build": "bun run scripts/build.ts",
|
|
19
19
|
"build:all": "bun run scripts/build.ts --all",
|
|
20
20
|
"desktop:view:build": "bun run scripts/build-electrobun-view.ts",
|
|
21
|
-
"desktop:dev": "electrobun
|
|
21
|
+
"desktop:dev": "bun run scripts/electrobun-dev.ts",
|
|
22
22
|
"desktop:build": "electrobun build",
|
|
23
23
|
"desktop:release": "electrobun build --env=stable",
|
|
24
24
|
"desktop:release:windows": "electrobun build --env=stable --targets=win-x64",
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createTestRenderer } from "@opentui/core/testing";
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createOpenTuiTestRoot as createRoot, TestDialogProvider } from "../renderers/opentui/test-utils";
|
|
5
|
-
import { createDefaultConfig } from "../types/config";
|
|
5
|
+
import { cloneLayout, createDefaultConfig } from "../types/config";
|
|
6
6
|
import { createInitialState, type AppAction, type AppState } from "../state/app/context";
|
|
7
7
|
import type { PluginRegistry } from "../plugins/registry";
|
|
8
8
|
import { useAppGlobalShortcuts } from "./global-shortcuts";
|
|
@@ -164,6 +164,25 @@ describe("useAppGlobalShortcuts", () => {
|
|
|
164
164
|
expect(event.propagationStopped).toBe(true);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
test("switches saved layouts with Ctrl-number and consumes the shortcut", async () => {
|
|
168
|
+
const actions: AppAction[] = [];
|
|
169
|
+
const config = createDefaultConfig("/tmp/gloomberb-global-shortcuts-layouts");
|
|
170
|
+
config.layouts = [
|
|
171
|
+
{ name: "One", layout: cloneLayout(config.layout) },
|
|
172
|
+
{ name: "Two", layout: cloneLayout(config.layout) },
|
|
173
|
+
{ name: "Three", layout: cloneLayout(config.layout) },
|
|
174
|
+
];
|
|
175
|
+
config.activeLayoutIndex = 0;
|
|
176
|
+
const state = createInitialState(config);
|
|
177
|
+
await renderHarness(state, createRegistry(), (action) => actions.push(action));
|
|
178
|
+
|
|
179
|
+
const event = await emitKeypress({ name: "2", ctrl: true });
|
|
180
|
+
|
|
181
|
+
expect(actions).toEqual([{ type: "SWITCH_LAYOUT", index: 1 }]);
|
|
182
|
+
expect(event.defaultPrevented).toBe(true);
|
|
183
|
+
expect(event.propagationStopped).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
167
186
|
test("does not run plain plugin shortcuts while input is captured", async () => {
|
|
168
187
|
let executed = 0;
|
|
169
188
|
const actions: AppAction[] = [];
|
|
@@ -73,23 +73,30 @@ export function useAppPaneRuntime({
|
|
|
73
73
|
return resolvePaneTargetInLayout(layout, paneId);
|
|
74
74
|
}, [state.config.layout]);
|
|
75
75
|
|
|
76
|
-
const persistLayout = useCallback((layout: LayoutConfig, options?: { pushHistory?: boolean }) => {
|
|
76
|
+
const persistLayout = useCallback((layout: LayoutConfig, options?: { pushHistory?: boolean; focusedPaneId?: string | null }) => {
|
|
77
77
|
const currentState = stateRef.current;
|
|
78
78
|
const normalizedLayout = normalizePaneLayout(layout);
|
|
79
79
|
if (options?.pushHistory !== false) {
|
|
80
80
|
dispatch({ type: "PUSH_LAYOUT_HISTORY" });
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
const hasFocusTarget = !!options && Object.prototype.hasOwnProperty.call(options, "focusedPaneId");
|
|
83
|
+
dispatch(hasFocusTarget
|
|
84
|
+
? { type: "UPDATE_LAYOUT", layout: normalizedLayout, focusedPaneId: options.focusedPaneId ?? null }
|
|
85
|
+
: { type: "UPDATE_LAYOUT", layout: normalizedLayout });
|
|
83
86
|
scheduleConfigSave(syncConfigActiveLayoutState(
|
|
84
87
|
{ ...currentState.config, layout: normalizedLayout },
|
|
85
88
|
currentState.paneState,
|
|
86
|
-
currentState.focusedPaneId,
|
|
89
|
+
hasFocusTarget ? (options.focusedPaneId ?? null) : currentState.focusedPaneId,
|
|
87
90
|
currentState.activePanel,
|
|
88
91
|
));
|
|
89
92
|
}, [dispatch, stateRef]);
|
|
90
93
|
|
|
91
94
|
const activatePane = useCallback((paneId: string, layout: LayoutConfig = state.config.layout) => {
|
|
92
|
-
dispatch({
|
|
95
|
+
dispatch({
|
|
96
|
+
type: "SET_ACTIVE_PANEL",
|
|
97
|
+
panel: resolvePanelForPane({ layout, paneId, pluginRegistry }),
|
|
98
|
+
preserveFocus: true,
|
|
99
|
+
});
|
|
93
100
|
dispatch({ type: "FOCUS_PANE", paneId });
|
|
94
101
|
}, [dispatch, pluginRegistry, state.config.layout]);
|
|
95
102
|
|
|
@@ -15,7 +15,7 @@ interface UseAppPaneSettingsRuntimeOptions {
|
|
|
15
15
|
dataProvider: DataProvider;
|
|
16
16
|
dialog: DialogApi;
|
|
17
17
|
dispatch: Dispatch<AppAction>;
|
|
18
|
-
persistLayout: (layout: LayoutConfig, options?: { pushHistory?: boolean }) => void;
|
|
18
|
+
persistLayout: (layout: LayoutConfig, options?: { pushHistory?: boolean; focusedPaneId?: string | null }) => void;
|
|
19
19
|
pluginRegistry: PluginRegistry;
|
|
20
20
|
resolvePaneTarget: (paneId: string, layout?: LayoutConfig) => string | null;
|
|
21
21
|
stateRef: { current: AppState };
|
package/src/app.tsx
CHANGED
|
@@ -39,6 +39,8 @@ import { bindPluginRegistryRuntimeAccess } from "./app/runtime/plugin-bindings";
|
|
|
39
39
|
import { useAppStartupRuntime } from "./app/runtime/startup";
|
|
40
40
|
import { useTickerRefreshRuntime } from "./app/runtime/ticker-refresh";
|
|
41
41
|
import { useAppUpdateRuntime } from "./app/runtime/update";
|
|
42
|
+
import { RemoteControlHost, type RemoteControlAdapter } from "./remote/app-host";
|
|
43
|
+
import { RemoteUiRegistryProvider } from "./remote/semantic-tree";
|
|
42
44
|
import {
|
|
43
45
|
resolveAppSessionSnapshot,
|
|
44
46
|
resolveCliLaunchConfig,
|
|
@@ -55,6 +57,7 @@ interface AppInnerProps {
|
|
|
55
57
|
sessionSnapshot?: AppSessionSnapshot | null;
|
|
56
58
|
desktopWindowBridge?: DesktopWindowBridge;
|
|
57
59
|
desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
|
|
60
|
+
remoteControlAdapter?: RemoteControlAdapter;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
function ThemedAppRoot({ children }: { children: ReactNode }) {
|
|
@@ -83,9 +86,11 @@ function AppInner({
|
|
|
83
86
|
sessionSnapshot = null,
|
|
84
87
|
desktopWindowBridge,
|
|
85
88
|
desktopApplicationMenuBridge,
|
|
89
|
+
remoteControlAdapter,
|
|
86
90
|
}: AppInnerProps) {
|
|
87
91
|
const dispatch = useAppDispatch();
|
|
88
92
|
const stateRef = useAppStateRef();
|
|
93
|
+
const getRemoteState = useCallback(() => stateRef.current, [stateRef]);
|
|
89
94
|
const config = useAppSelector((state) => state.config);
|
|
90
95
|
const tickers = useAppSelector((state) => state.tickers);
|
|
91
96
|
const paneState = useAppSelector((state) => state.paneState);
|
|
@@ -288,6 +293,13 @@ function AppInner({
|
|
|
288
293
|
if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
|
|
289
294
|
return (
|
|
290
295
|
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
296
|
+
<RemoteControlHost
|
|
297
|
+
adapter={remoteControlAdapter}
|
|
298
|
+
dispatch={dispatch}
|
|
299
|
+
getState={getRemoteState}
|
|
300
|
+
pluginRegistry={pluginRegistry}
|
|
301
|
+
desktopWindowBridge={desktopWindowBridge}
|
|
302
|
+
/>
|
|
291
303
|
<ThemedAppRoot>
|
|
292
304
|
<DetachedPaneShell
|
|
293
305
|
pluginRegistry={pluginRegistry}
|
|
@@ -301,6 +313,13 @@ function AppInner({
|
|
|
301
313
|
|
|
302
314
|
return (
|
|
303
315
|
<ContextMenuProvider pluginRegistry={pluginRegistry}>
|
|
316
|
+
<RemoteControlHost
|
|
317
|
+
adapter={remoteControlAdapter}
|
|
318
|
+
dispatch={dispatch}
|
|
319
|
+
getState={getRemoteState}
|
|
320
|
+
pluginRegistry={pluginRegistry}
|
|
321
|
+
desktopWindowBridge={desktopWindowBridge}
|
|
322
|
+
/>
|
|
304
323
|
<ThemedAppRoot>
|
|
305
324
|
<Header />
|
|
306
325
|
<TransientLayoutProvider>
|
|
@@ -336,6 +355,7 @@ interface AppProps {
|
|
|
336
355
|
desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
|
|
337
356
|
desktopSnapshot?: DesktopSharedStateSnapshot | null;
|
|
338
357
|
desktopThemePreview?: DesktopThemePreviewState | null;
|
|
358
|
+
remoteControlAdapter?: RemoteControlAdapter;
|
|
339
359
|
}
|
|
340
360
|
|
|
341
361
|
export function App({
|
|
@@ -346,6 +366,7 @@ export function App({
|
|
|
346
366
|
desktopApplicationMenuBridge,
|
|
347
367
|
desktopSnapshot = null,
|
|
348
368
|
desktopThemePreview = null,
|
|
369
|
+
remoteControlAdapter,
|
|
349
370
|
}: AppProps) {
|
|
350
371
|
const renderer = useNativeRenderer();
|
|
351
372
|
const effectiveInitialConfig = useMemo(() => {
|
|
@@ -413,23 +434,26 @@ export function App({
|
|
|
413
434
|
}
|
|
414
435
|
|
|
415
436
|
return (
|
|
416
|
-
<
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
desktopBridge={desktopWindowBridge}
|
|
421
|
-
desktopSnapshot={desktopSnapshot}
|
|
422
|
-
initialThemePreview={desktopThemePreview}
|
|
423
|
-
>
|
|
424
|
-
<AppInner
|
|
425
|
-
pluginRegistry={services.pluginRegistry}
|
|
426
|
-
tickerRepository={services.tickerRepository}
|
|
427
|
-
dataProvider={services.dataProvider}
|
|
428
|
-
marketData={services.marketData}
|
|
437
|
+
<RemoteUiRegistryProvider>
|
|
438
|
+
<AppProvider
|
|
439
|
+
config={config}
|
|
440
|
+
sessionStore={desktopWindowBridge?.kind === "detached" ? undefined : services.persistence.sessions}
|
|
429
441
|
sessionSnapshot={sessionSnapshot}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
442
|
+
desktopBridge={desktopWindowBridge}
|
|
443
|
+
desktopSnapshot={desktopSnapshot}
|
|
444
|
+
initialThemePreview={desktopThemePreview}
|
|
445
|
+
>
|
|
446
|
+
<AppInner
|
|
447
|
+
pluginRegistry={services.pluginRegistry}
|
|
448
|
+
tickerRepository={services.tickerRepository}
|
|
449
|
+
dataProvider={services.dataProvider}
|
|
450
|
+
marketData={services.marketData}
|
|
451
|
+
sessionSnapshot={sessionSnapshot}
|
|
452
|
+
desktopWindowBridge={desktopWindowBridge}
|
|
453
|
+
desktopApplicationMenuBridge={desktopApplicationMenuBridge}
|
|
454
|
+
remoteControlAdapter={remoteControlAdapter}
|
|
455
|
+
/>
|
|
456
|
+
</AppProvider>
|
|
457
|
+
</RemoteUiRegistryProvider>
|
|
434
458
|
);
|
|
435
459
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { getDataDir } from "../../data/config/store";
|
|
2
|
+
import type { CliCommandContext, CliCommandDef } from "../../types/plugin";
|
|
3
|
+
import { sendRemoteControlRequest } from "../../remote/client";
|
|
4
|
+
import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse } from "../../remote/types";
|
|
5
|
+
|
|
6
|
+
interface RemoteArgs {
|
|
7
|
+
args: string[];
|
|
8
|
+
appKind?: RemoteAppKind;
|
|
9
|
+
expectRev?: string;
|
|
10
|
+
intervalMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const remoteCliCommand: CliCommandDef = {
|
|
14
|
+
name: "remote",
|
|
15
|
+
description: "Control a running app through the semantic remote API",
|
|
16
|
+
help: {
|
|
17
|
+
usage: [
|
|
18
|
+
"remote schema [--app tui|desktop]",
|
|
19
|
+
"remote help [--app tui|desktop]",
|
|
20
|
+
"remote get <resource> [--app tui|desktop]",
|
|
21
|
+
"remote call <operation> [json] [--dry-run] [--app tui|desktop]",
|
|
22
|
+
"remote patch <resource> <json-patch> [--expect-rev rev] [--dry-run] [--app tui|desktop]",
|
|
23
|
+
"remote batch <json> [--dry-run] [--app tui|desktop]",
|
|
24
|
+
"remote watch <resource> [--interval ms] [--limit n] [--app tui|desktop]",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
execute: async (rawArgs, ctx) => {
|
|
28
|
+
const parsed = parseRemoteArgs(rawArgs);
|
|
29
|
+
const action = parsed.args[0];
|
|
30
|
+
if (!action) {
|
|
31
|
+
ctx.fail("Usage: gloomberb remote <schema|help|get|call|patch|batch|watch>");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dataDir = await getDataDir();
|
|
36
|
+
if (!dataDir) {
|
|
37
|
+
ctx.fail("No data directory configured.", "Run gloomberb once before using remote control.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (action === "watch") {
|
|
42
|
+
const resource = parsed.args[1];
|
|
43
|
+
if (!resource) {
|
|
44
|
+
ctx.fail("Usage: gloomberb remote watch <resource>");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const limit = ctx.cliOptions.limit ?? Number.POSITIVE_INFINITY;
|
|
48
|
+
for (let index = 0; index < limit; index += 1) {
|
|
49
|
+
const response = await sendRemoteControlRequest({ type: "get", resource }, {
|
|
50
|
+
dataDir,
|
|
51
|
+
appKind: parsed.appKind,
|
|
52
|
+
});
|
|
53
|
+
printRemoteResponse(response, ctx);
|
|
54
|
+
if (index < limit - 1) await new Promise((resolve) => setTimeout(resolve, parsed.intervalMs));
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const request = buildRemoteRequest(action, parsed, ctx.cliOptions.dryRun);
|
|
60
|
+
const response = await sendRemoteControlRequest(request, {
|
|
61
|
+
dataDir,
|
|
62
|
+
appKind: parsed.appKind,
|
|
63
|
+
});
|
|
64
|
+
printRemoteResponse(response, ctx);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function parseRemoteArgs(rawArgs: string[]): RemoteArgs {
|
|
69
|
+
const args: string[] = [];
|
|
70
|
+
let appKind: RemoteAppKind | undefined;
|
|
71
|
+
let expectRev: string | undefined;
|
|
72
|
+
let intervalMs = 1_000;
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
75
|
+
const arg = rawArgs[index]!;
|
|
76
|
+
if (arg === "--app") {
|
|
77
|
+
index += 1;
|
|
78
|
+
appKind = parseAppKind(rawArgs[index]);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (arg.startsWith("--app=")) {
|
|
82
|
+
appKind = parseAppKind(arg.slice("--app=".length));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg === "--expect-rev") {
|
|
86
|
+
index += 1;
|
|
87
|
+
expectRev = rawArgs[index];
|
|
88
|
+
if (!expectRev) throw new Error("Missing value for --expect-rev.");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg.startsWith("--expect-rev=")) {
|
|
92
|
+
expectRev = arg.slice("--expect-rev=".length);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (arg === "--interval") {
|
|
96
|
+
index += 1;
|
|
97
|
+
intervalMs = parseInterval(rawArgs[index]);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (arg.startsWith("--interval=")) {
|
|
101
|
+
intervalMs = parseInterval(arg.slice("--interval=".length));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
args.push(arg);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { args, appKind, expectRev, intervalMs };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildRemoteRequest(action: string, parsed: RemoteArgs, dryRun: boolean): RemoteControlRequest {
|
|
111
|
+
switch (action) {
|
|
112
|
+
case "schema":
|
|
113
|
+
return { type: "schema" };
|
|
114
|
+
case "help":
|
|
115
|
+
return { type: "help" };
|
|
116
|
+
case "get": {
|
|
117
|
+
const resource = parsed.args[1];
|
|
118
|
+
if (!resource) throw new Error("Usage: gloomberb remote get <resource>");
|
|
119
|
+
return { type: "get", resource };
|
|
120
|
+
}
|
|
121
|
+
case "call": {
|
|
122
|
+
const operation = parsed.args[1];
|
|
123
|
+
if (!operation) throw new Error("Usage: gloomberb remote call <operation> [json]");
|
|
124
|
+
return {
|
|
125
|
+
type: "call",
|
|
126
|
+
operation,
|
|
127
|
+
input: parseJsonValue(parsed.args[2], {}),
|
|
128
|
+
dryRun,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
case "patch": {
|
|
132
|
+
const resource = parsed.args[1];
|
|
133
|
+
const patchRaw = parsed.args[2];
|
|
134
|
+
if (!resource || !patchRaw) throw new Error("Usage: gloomberb remote patch <resource> <json-patch>");
|
|
135
|
+
const patch = parseJsonValue(patchRaw, []);
|
|
136
|
+
if (!Array.isArray(patch)) throw new Error("Remote patch payload must be a JSON array.");
|
|
137
|
+
return {
|
|
138
|
+
type: "patch",
|
|
139
|
+
resource,
|
|
140
|
+
patch,
|
|
141
|
+
expectRev: parsed.expectRev,
|
|
142
|
+
dryRun,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
case "batch": {
|
|
146
|
+
const payload = parseJsonValue(parsed.args[1], null);
|
|
147
|
+
if (!payload) throw new Error("Usage: gloomberb remote batch <json>");
|
|
148
|
+
if (Array.isArray(payload)) return { type: "batch", requests: payload as RemoteControlRequest[], dryRun };
|
|
149
|
+
return withCliDryRun(payload as RemoteControlRequest, dryRun);
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`Unknown remote action "${action}".`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function withCliDryRun(request: RemoteControlRequest, dryRun: boolean): RemoteControlRequest {
|
|
157
|
+
if (!dryRun) return request;
|
|
158
|
+
if (request.type === "batch") return { ...request, dryRun: true };
|
|
159
|
+
if (request.type === "call" || request.type === "patch") return { ...request, dryRun: true };
|
|
160
|
+
return request;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseAppKind(value: string | undefined): RemoteAppKind {
|
|
164
|
+
if (value === "tui" || value === "desktop") return value;
|
|
165
|
+
throw new Error("--app must be either tui or desktop.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseInterval(value: string | undefined): number {
|
|
169
|
+
const parsed = Number(value);
|
|
170
|
+
if (!Number.isInteger(parsed) || parsed < 100) {
|
|
171
|
+
throw new Error("--interval must be an integer >= 100.");
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseJsonValue<T>(raw: string | undefined, fallback: T): unknown {
|
|
177
|
+
if (raw == null) return fallback;
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(raw);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function printRemoteResponse(
|
|
186
|
+
response: RemoteControlResponse,
|
|
187
|
+
ctx: CliCommandContext,
|
|
188
|
+
): void {
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const details = response.error.details == null
|
|
191
|
+
? undefined
|
|
192
|
+
: typeof response.error.details === "string"
|
|
193
|
+
? response.error.details
|
|
194
|
+
: JSON.stringify(response.error.details, null, 2);
|
|
195
|
+
ctx.fail(response.error.message, details);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const data = response.state ? { data: response.data, state: response.state } : response.data;
|
|
199
|
+
ctx.printResult({
|
|
200
|
+
data,
|
|
201
|
+
metadata: response.rev ? { rev: response.rev } : undefined,
|
|
202
|
+
warnings: response.warnings,
|
|
203
|
+
}, {
|
|
204
|
+
text: (data) => JSON.stringify(data, null, 2),
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ticker, buildTickerReport } from "./commands/ticker";
|
|
|
18
18
|
import { apiCliCommand } from "./commands/api";
|
|
19
19
|
import { marketDataCliCommands } from "./commands/market";
|
|
20
20
|
import { overviewCliCommands } from "./commands/overview";
|
|
21
|
+
import { remoteCliCommand } from "./commands/remote";
|
|
21
22
|
import { createSystemCliCommands } from "./commands/system";
|
|
22
23
|
import {
|
|
23
24
|
aiCliCommand,
|
|
@@ -188,6 +189,7 @@ function createCoreCliCommands(renderHelp: () => string): CliCommandDef[] {
|
|
|
188
189
|
apiCliCommand,
|
|
189
190
|
...marketDataCliCommands,
|
|
190
191
|
...overviewCliCommands,
|
|
192
|
+
remoteCliCommand,
|
|
191
193
|
...createSystemCliCommands(() => commands),
|
|
192
194
|
brokerCliCommand,
|
|
193
195
|
ibkrCliCommand,
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
export function resolveChartKeyboardKey(event: {
|
|
1
|
+
export function resolveChartKeyboardKey(event: {
|
|
2
|
+
name?: string;
|
|
3
|
+
sequence?: string;
|
|
4
|
+
ctrl?: boolean;
|
|
5
|
+
meta?: boolean;
|
|
6
|
+
alt?: boolean;
|
|
7
|
+
super?: boolean;
|
|
8
|
+
}): string {
|
|
9
|
+
if (event.ctrl || event.meta || event.alt || event.super) return "";
|
|
10
|
+
|
|
2
11
|
const name = event.name ?? "";
|
|
3
12
|
const sequence = event.sequence ?? "";
|
|
4
13
|
const candidates = [name, sequence];
|
|
@@ -47,6 +47,8 @@ describe("StaticBarChartSurface", () => {
|
|
|
47
47
|
|
|
48
48
|
await act(async () => {
|
|
49
49
|
await testSetup!.mockMouse.moveTo(14, 4);
|
|
50
|
+
});
|
|
51
|
+
await act(async () => {
|
|
50
52
|
await testSetup!.renderOnce();
|
|
51
53
|
await testSetup!.renderOnce();
|
|
52
54
|
});
|
|
@@ -86,6 +88,8 @@ describe("StaticBarChartSurface", () => {
|
|
|
86
88
|
await testSetup!.renderOnce();
|
|
87
89
|
await testSetup!.renderOnce();
|
|
88
90
|
await testSetup!.mockMouse.moveTo(14, 4);
|
|
91
|
+
});
|
|
92
|
+
await act(async () => {
|
|
89
93
|
await testSetup!.renderOnce();
|
|
90
94
|
await testSetup!.renderOnce();
|
|
91
95
|
});
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import { act, useEffect } from "react";
|
|
3
3
|
import { testRender } from "../../../../renderers/opentui/test-utils";
|
|
4
4
|
import { colors } from "../../../../theme/colors";
|
|
5
|
+
import { RemoteUiRegistryProvider, useRemoteUiRegistry, type RemoteUiRegistry } from "../../../../remote/semantic-tree";
|
|
5
6
|
import { resolveChartPalette } from "../../core/renderer";
|
|
6
7
|
import { StaticChartSurface } from "./surface";
|
|
7
8
|
import type { ProjectedChartPoint } from "../../core/data";
|
|
8
9
|
|
|
9
10
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
11
|
+
let remoteRegistry: RemoteUiRegistry | null = null;
|
|
10
12
|
|
|
11
13
|
afterEach(async () => {
|
|
12
|
-
|
|
14
|
+
const setup = testSetup;
|
|
15
|
+
testSetup = undefined;
|
|
16
|
+
remoteRegistry = null;
|
|
17
|
+
if (!setup) return;
|
|
13
18
|
await act(async () => {
|
|
14
|
-
|
|
19
|
+
setup.renderer.destroy();
|
|
15
20
|
});
|
|
16
|
-
testSetup = undefined;
|
|
17
21
|
});
|
|
18
22
|
|
|
19
23
|
const points: ProjectedChartPoint[] = [
|
|
@@ -22,6 +26,14 @@ const points: ProjectedChartPoint[] = [
|
|
|
22
26
|
{ date: new Date("2026-01-03"), open: 4.9, high: 4.9, low: 4.9, close: 4.9, volume: 0 },
|
|
23
27
|
];
|
|
24
28
|
|
|
29
|
+
function RemoteRegistryProbe() {
|
|
30
|
+
const registry = useRemoteUiRegistry();
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
remoteRegistry = registry;
|
|
33
|
+
}, [registry]);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
describe("StaticChartSurface", () => {
|
|
26
38
|
test("renders unit and custom y-axis labels", async () => {
|
|
27
39
|
testSetup = await testRender(
|
|
@@ -132,6 +144,49 @@ describe("StaticChartSurface", () => {
|
|
|
132
144
|
|
|
133
145
|
await act(async () => {
|
|
134
146
|
await testSetup!.mockMouse.moveTo(20, 4);
|
|
147
|
+
});
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await testSetup!.renderOnce();
|
|
150
|
+
await testSetup!.renderOnce();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(testSetup.captureCharFrame()).not.toBe(initialFrame);
|
|
154
|
+
expect(testSetup.captureCharFrame()).toContain("X49");
|
|
155
|
+
expect(testSetup.captureCharFrame()).toContain("Y3.97");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("exposes semantic remote cursor control", async () => {
|
|
159
|
+
testSetup = await testRender(
|
|
160
|
+
<RemoteUiRegistryProvider>
|
|
161
|
+
<RemoteRegistryProbe />
|
|
162
|
+
<StaticChartSurface
|
|
163
|
+
points={points}
|
|
164
|
+
width={48}
|
|
165
|
+
height={10}
|
|
166
|
+
mode="line"
|
|
167
|
+
colors={resolveChartPalette(colors, "positive")}
|
|
168
|
+
xAxisLabels={["0%", "50%", "100%"]}
|
|
169
|
+
formatXAxisCursorValue={(ratio) => `X${Math.round(ratio * 100)}`}
|
|
170
|
+
formatYAxisValue={(value) => `Y${value.toFixed(2)}`}
|
|
171
|
+
/>
|
|
172
|
+
</RemoteUiRegistryProvider>,
|
|
173
|
+
{ width: 50, height: 12 },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await testSetup!.renderOnce();
|
|
178
|
+
await testSetup!.renderOnce();
|
|
179
|
+
});
|
|
180
|
+
const initialFrame = testSetup.captureCharFrame();
|
|
181
|
+
const chartNode = remoteRegistry?.snapshot().find((node) => (
|
|
182
|
+
node.role === "chart" && node.metadata?.kind === "static-chart"
|
|
183
|
+
));
|
|
184
|
+
expect(chartNode?.actions).toContain("moveCursor");
|
|
185
|
+
|
|
186
|
+
await act(async () => {
|
|
187
|
+
await remoteRegistry!.invoke(chartNode!.id, "moveCursor", { x: 20, y: 4 });
|
|
188
|
+
});
|
|
189
|
+
await act(async () => {
|
|
135
190
|
await testSetup!.renderOnce();
|
|
136
191
|
await testSetup!.renderOnce();
|
|
137
192
|
});
|