gloomberb 0.9.5 → 0.9.6

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.
Files changed (92) hide show
  1. package/README.md +49 -8
  2. package/package.json +2 -2
  3. package/src/app/global-shortcuts.test.tsx +20 -1
  4. package/src/app/global-shortcuts.ts +2 -0
  5. package/src/app/pane-runtime/index.tsx +11 -4
  6. package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
  7. package/src/app.tsx +41 -17
  8. package/src/cli/commands/remote.ts +206 -0
  9. package/src/cli/index.ts +2 -0
  10. package/src/components/chart/core/keyboard.ts +10 -1
  11. package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
  12. package/src/components/chart/static/chart/surface.test.tsx +59 -4
  13. package/src/components/chart/static/chart/surface.tsx +4 -0
  14. package/src/components/chart/stock-chart/index.test.ts +7 -0
  15. package/src/components/command-bar/list/view.tsx +47 -0
  16. package/src/components/command-bar/multi-select-picker.tsx +7 -0
  17. package/src/components/command-bar/routing/navigation-state.ts +10 -1
  18. package/src/components/command-bar/surface/index.test.tsx +14 -0
  19. package/src/components/command-bar/theme-picker.tsx +91 -79
  20. package/src/components/command-bar/workflow/field-row.tsx +30 -5
  21. package/src/components/index.ts +7 -0
  22. package/src/components/layout/pane/footer/index.tsx +23 -0
  23. package/src/components/layout/shell/index.test.tsx +2 -0
  24. package/src/components/layout/shell/index.tsx +8 -3
  25. package/src/components/layout/shell/pane/actions.ts +21 -7
  26. package/src/components/metric-treemap/index.tsx +317 -0
  27. package/src/components/metric-treemap/layout.test.ts +77 -0
  28. package/src/components/metric-treemap/layout.ts +476 -0
  29. package/src/components/toggle-list.tsx +10 -0
  30. package/src/components/ui/button.tsx +12 -0
  31. package/src/components/ui/data-table/index.tsx +51 -0
  32. package/src/components/ui/fields.tsx +18 -0
  33. package/src/components/ui/list-view.tsx +83 -3
  34. package/src/components/ui/message-composer.tsx +22 -0
  35. package/src/components/ui/tabs.tsx +45 -0
  36. package/src/core/state/app/layout-reducer.ts +7 -1
  37. package/src/core/state/app/layout.ts +6 -0
  38. package/src/core/state/app/state.ts +23 -1
  39. package/src/core/state/app/types.ts +4 -2
  40. package/src/plugins/builtin/chat/channels.ts +5 -18
  41. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  42. package/src/plugins/builtin/chat/status-widget.tsx +1 -7
  43. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  44. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  45. package/src/plugins/builtin/holders/types.ts +0 -34
  46. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  47. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  48. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  49. package/src/plugins/builtin/plugin-groups.ts +2 -0
  50. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  51. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +41 -0
  52. package/src/plugins/builtin/portfolio-list/pane/index.tsx +78 -22
  53. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  54. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  55. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  56. package/src/remote/app-host.tsx +77 -0
  57. package/src/remote/client.ts +34 -0
  58. package/src/remote/command-bar.ts +98 -0
  59. package/src/remote/controller-utils.ts +111 -0
  60. package/src/remote/controller.test.ts +365 -0
  61. package/src/remote/controller.ts +434 -0
  62. package/src/remote/endpoint.ts +41 -0
  63. package/src/remote/json-patch.test.ts +31 -0
  64. package/src/remote/json-patch.ts +89 -0
  65. package/src/remote/layout-helpers.ts +62 -0
  66. package/src/remote/resources.ts +186 -0
  67. package/src/remote/revision.ts +18 -0
  68. package/src/remote/schema.ts +143 -0
  69. package/src/remote/semantic-helpers.ts +161 -0
  70. package/src/remote/semantic-tree.tsx +112 -0
  71. package/src/remote/server.test.ts +50 -0
  72. package/src/remote/server.ts +121 -0
  73. package/src/remote/types.ts +119 -0
  74. package/src/renderers/electrobun/bun/index.ts +55 -1
  75. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  76. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  77. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  78. package/src/renderers/electrobun/view/host/box.tsx +5 -0
  79. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  80. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  81. package/src/renderers/electrobun/view/main.tsx +5 -1
  82. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  83. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  84. package/src/renderers/opentui/chart-surface.tsx +15 -20
  85. package/src/renderers/opentui/start.tsx +32 -0
  86. package/src/sources/yahoo-finance/http.ts +31 -4
  87. package/src/state/app/context/index.test.tsx +47 -0
  88. package/src/state/app/context/index.tsx +48 -18
  89. package/src/ui/host.tsx +1 -0
  90. package/src/ui/index.tsx +183 -7
  91. package/src/version.ts +1 -1
  92. 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
- Available as a desktop app or TUI.
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 for Mac</strong></a>
11
+ <a href="https://gloomberb.com/download/desktop"><strong>Download desktop</strong></a>
12
+ &nbsp;&middot;&nbsp;
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 desktop app + terminal command:
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` terminal command that runs the TUI through the app bundle, so the Bun runtime is stored once.
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
- Desktop-only download:
47
+ Prefer a direct download?
33
48
 
34
49
  - [Download Gloomberb for Mac](https://gloomberb.com/download/desktop)
35
50
 
36
- Terminal-only install:
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, app updates replace the app bundle in place and keep the terminal command pointing at the updated bundle. Homebrew users can also update through `brew upgrade --cask gloomberb`.
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.5",
3
+ "version": "0.9.6",
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 dev --watch",
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[] = [];
@@ -76,6 +76,8 @@ export function useAppGlobalShortcuts({
76
76
  if (idx < layouts.length && idx !== state.config.activeLayoutIndex) {
77
77
  dispatch({ type: "SWITCH_LAYOUT", index: idx });
78
78
  }
79
+ event.preventDefault();
80
+ event.stopPropagation();
79
81
  return;
80
82
  }
81
83
 
@@ -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
- dispatch({ type: "UPDATE_LAYOUT", layout: normalizedLayout });
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({ type: "SET_ACTIVE_PANEL", panel: resolvePanelForPane({ layout, paneId, pluginRegistry }) });
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
- <AppProvider
417
- config={config}
418
- sessionStore={desktopWindowBridge?.kind === "detached" ? undefined : services.persistence.sessions}
419
- sessionSnapshot={sessionSnapshot}
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
- desktopWindowBridge={desktopWindowBridge}
431
- desktopApplicationMenuBridge={desktopApplicationMenuBridge}
432
- />
433
- </AppProvider>
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: { name?: string; sequence?: string }): string {
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
- if (!testSetup) return;
14
+ const setup = testSetup;
15
+ testSetup = undefined;
16
+ remoteRegistry = null;
17
+ if (!setup) return;
13
18
  await act(async () => {
14
- testSetup!.renderer.destroy();
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
  });