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.
Files changed (99) 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/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/chat/channels.ts +5 -18
  42. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  43. package/src/plugins/builtin/chat/status-widget.tsx +1 -7
  44. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  45. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  46. package/src/plugins/builtin/holders/types.ts +0 -34
  47. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  48. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  49. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  50. package/src/plugins/builtin/plugin-groups.ts +2 -0
  51. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  52. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
  53. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  54. package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
  55. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
  57. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
  58. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  59. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  60. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  61. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  62. package/src/remote/app-host.tsx +77 -0
  63. package/src/remote/client.ts +34 -0
  64. package/src/remote/command-bar.ts +98 -0
  65. package/src/remote/controller-utils.ts +111 -0
  66. package/src/remote/controller.test.ts +365 -0
  67. package/src/remote/controller.ts +434 -0
  68. package/src/remote/endpoint.ts +41 -0
  69. package/src/remote/json-patch.test.ts +31 -0
  70. package/src/remote/json-patch.ts +89 -0
  71. package/src/remote/layout-helpers.ts +62 -0
  72. package/src/remote/resources.ts +186 -0
  73. package/src/remote/revision.ts +18 -0
  74. package/src/remote/schema.ts +143 -0
  75. package/src/remote/semantic-helpers.ts +161 -0
  76. package/src/remote/semantic-tree.tsx +112 -0
  77. package/src/remote/server.test.ts +50 -0
  78. package/src/remote/server.ts +121 -0
  79. package/src/remote/types.ts +119 -0
  80. package/src/renderers/electrobun/bun/index.ts +55 -1
  81. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  82. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  83. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  84. package/src/renderers/electrobun/view/host/box.tsx +5 -0
  85. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  86. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  87. package/src/renderers/electrobun/view/main.tsx +5 -1
  88. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  89. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  90. package/src/renderers/opentui/chart-surface.tsx +15 -20
  91. package/src/renderers/opentui/start.tsx +32 -0
  92. package/src/sources/yahoo-finance/http.ts +31 -4
  93. package/src/state/app/context/index.test.tsx +47 -0
  94. package/src/state/app/context/index.tsx +48 -18
  95. package/src/types/config.ts +1 -0
  96. package/src/ui/host.tsx +1 -0
  97. package/src/ui/index.tsx +183 -7
  98. package/src/version.ts +1 -1
  99. package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from "crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "http";
3
+ import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse, RemoteEndpoint } from "./types";
4
+ import { REMOTE_CONTROL_PROTOCOL_VERSION } from "./types";
5
+ import { removeRemoteEndpointFiles, writeRemoteEndpointFiles } from "./endpoint";
6
+
7
+ export interface RemoteControlServerOptions {
8
+ dataDir: string;
9
+ appKind: RemoteAppKind;
10
+ handle: (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
11
+ }
12
+
13
+ export interface RemoteControlServer {
14
+ endpoint: RemoteEndpoint;
15
+ close(): Promise<void>;
16
+ }
17
+
18
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
19
+
20
+ export async function startRemoteControlServer(options: RemoteControlServerOptions): Promise<RemoteControlServer> {
21
+ const token = randomUUID();
22
+ const httpServer = createServer((request, response) => {
23
+ void handleHttpRequest(options.handle, token, request, response);
24
+ });
25
+
26
+ await new Promise<void>((resolve, reject) => {
27
+ httpServer.once("error", reject);
28
+ httpServer.listen(0, "127.0.0.1", () => {
29
+ httpServer.off("error", reject);
30
+ resolve();
31
+ });
32
+ });
33
+
34
+ const address = httpServer.address();
35
+ if (!address || typeof address === "string") {
36
+ httpServer.close();
37
+ throw new Error("Remote control server did not bind to a TCP port.");
38
+ }
39
+
40
+ const endpoint: RemoteEndpoint = {
41
+ protocolVersion: REMOTE_CONTROL_PROTOCOL_VERSION,
42
+ appKind: options.appKind,
43
+ pid: process.pid,
44
+ port: address.port,
45
+ token,
46
+ startedAt: new Date().toISOString(),
47
+ };
48
+ await writeRemoteEndpointFiles(options.dataDir, endpoint);
49
+
50
+ return {
51
+ endpoint,
52
+ close: async () => {
53
+ await new Promise<void>((resolve, reject) => {
54
+ httpServer.close((error) => (error ? reject(error) : resolve()));
55
+ }).catch((error) => {
56
+ if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
57
+ });
58
+ await removeRemoteEndpointFiles(options.dataDir, endpoint);
59
+ },
60
+ };
61
+ }
62
+
63
+ async function handleHttpRequest(
64
+ handle: RemoteControlServerOptions["handle"],
65
+ token: string,
66
+ request: IncomingMessage,
67
+ response: ServerResponse,
68
+ ): Promise<void> {
69
+ try {
70
+ if (request.method === "GET" && request.url === "/health") {
71
+ sendJson(response, 200, { ok: true });
72
+ return;
73
+ }
74
+ if (request.method !== "POST" || request.url !== "/rpc") {
75
+ sendJson(response, 404, { ok: false, error: { code: "not_found", message: "Unknown remote control endpoint." } });
76
+ return;
77
+ }
78
+ if (request.headers.authorization !== `Bearer ${token}`) {
79
+ sendJson(response, 401, { ok: false, error: { code: "unauthorized", message: "Invalid remote control token." } });
80
+ return;
81
+ }
82
+
83
+ const body = await readBody(request);
84
+ const remoteRequest = JSON.parse(body) as RemoteControlRequest;
85
+ sendJson(response, 200, await handle(remoteRequest));
86
+ } catch (error) {
87
+ sendJson(response, 500, {
88
+ ok: false,
89
+ error: {
90
+ code: "remote_transport_error",
91
+ message: error instanceof Error ? error.message : String(error),
92
+ },
93
+ });
94
+ }
95
+ }
96
+
97
+ async function readBody(request: IncomingMessage): Promise<string> {
98
+ let total = 0;
99
+ const chunks: Uint8Array[] = [];
100
+ for await (const chunk of request) {
101
+ const bytes = chunk instanceof Uint8Array ? new Uint8Array(chunk) : new TextEncoder().encode(String(chunk));
102
+ total += bytes.byteLength;
103
+ if (total > MAX_BODY_BYTES) throw new Error("Remote control request is too large.");
104
+ chunks.push(bytes);
105
+ }
106
+ const body = new Uint8Array(total);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ body.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ return new TextDecoder().decode(body);
113
+ }
114
+
115
+ function sendJson(response: ServerResponse, status: number, body: unknown): void {
116
+ response.writeHead(status, {
117
+ "content-type": "application/json; charset=utf-8",
118
+ "cache-control": "no-store",
119
+ });
120
+ response.end(JSON.stringify(body));
121
+ }
@@ -0,0 +1,119 @@
1
+ export const REMOTE_CONTROL_PROTOCOL_VERSION = 1;
2
+
3
+ export type RemoteAppKind = "tui" | "desktop";
4
+
5
+ export interface RemoteEndpoint {
6
+ protocolVersion: number;
7
+ appKind: RemoteAppKind;
8
+ pid: number;
9
+ port: number;
10
+ token: string;
11
+ startedAt: string;
12
+ }
13
+
14
+ export interface RemoteJsonPatchOperation {
15
+ op: "add" | "replace" | "remove";
16
+ path: string;
17
+ value?: unknown;
18
+ }
19
+
20
+ export type RemoteStateInclude =
21
+ | "app"
22
+ | "layout"
23
+ | "panes"
24
+ | "commandBar"
25
+ | "commandBar.results"
26
+ | "ui"
27
+ | "schema"
28
+ | "help"
29
+ | "all";
30
+
31
+ export type RemoteControlRequest =
32
+ | { type: "help"; topic?: string }
33
+ | { type: "schema" }
34
+ | { type: "get"; resource: string; include?: RemoteStateInclude[] }
35
+ | { type: "call"; operation: string; input?: unknown; dryRun?: boolean; include?: RemoteStateInclude[] }
36
+ | {
37
+ type: "patch";
38
+ resource: string;
39
+ patch: RemoteJsonPatchOperation[];
40
+ expectRev?: string;
41
+ dryRun?: boolean;
42
+ include?: RemoteStateInclude[];
43
+ }
44
+ | {
45
+ type: "batch";
46
+ requests: RemoteControlRequest[];
47
+ dryRun?: boolean;
48
+ haltOnError?: boolean;
49
+ settle?: "none" | "afterEach" | "afterBatch";
50
+ include?: RemoteStateInclude[];
51
+ };
52
+
53
+ export interface RemoteIncludedState {
54
+ rev: string;
55
+ included: RemoteStateInclude[];
56
+ app?: unknown;
57
+ layout?: unknown;
58
+ panes?: unknown;
59
+ commandBar?: unknown;
60
+ ui?: RemoteUiNodeSnapshot[];
61
+ schema?: RemoteControlSchema;
62
+ help?: unknown;
63
+ }
64
+
65
+ export interface RemoteControlSuccess<T = unknown> {
66
+ ok: true;
67
+ data: T;
68
+ rev?: string;
69
+ state?: RemoteIncludedState;
70
+ warnings?: string[];
71
+ }
72
+
73
+ export interface RemoteControlFailure {
74
+ ok: false;
75
+ error: {
76
+ code: string;
77
+ message: string;
78
+ details?: unknown;
79
+ };
80
+ }
81
+
82
+ export type RemoteControlResponse<T = unknown> =
83
+ | RemoteControlSuccess<T>
84
+ | RemoteControlFailure;
85
+
86
+ export interface RemoteResourceSchema {
87
+ uri: string;
88
+ description: string;
89
+ patchable?: boolean;
90
+ }
91
+
92
+ export interface RemoteOperationSchema {
93
+ id: string;
94
+ description: string;
95
+ inputShape?: string;
96
+ outputShape?: string;
97
+ returns?: string;
98
+ recommendedNextReads?: string[];
99
+ examples?: unknown[];
100
+ sideEffectLevel: "none" | "local-write" | "network-write" | "external-side-effect" | "external-trade";
101
+ requiresConfirmation?: boolean;
102
+ dryRun?: boolean;
103
+ }
104
+
105
+ export interface RemoteControlSchema {
106
+ protocolVersion: number;
107
+ resources: RemoteResourceSchema[];
108
+ operations: RemoteOperationSchema[];
109
+ help: unknown;
110
+ }
111
+
112
+ export interface RemoteUiNodeSnapshot {
113
+ id: string;
114
+ role: string;
115
+ label?: string;
116
+ disabled?: boolean;
117
+ actions: string[];
118
+ metadata?: Record<string, unknown>;
119
+ }
@@ -55,6 +55,8 @@ import {
55
55
  desktopWindowStyleMask,
56
56
  } from "./desktop/window-style";
57
57
  import { applyDesktopWindowControl, type DesktopWindowControlAction } from "./desktop/window-controls";
58
+ import { startRemoteControlServer, type RemoteControlServer } from "../../../remote/server";
59
+ import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
58
60
 
59
61
  type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
60
62
 
@@ -70,6 +72,7 @@ let services: AppServices | null = null;
70
72
  let mainWindow: BrowserWindow | null = null;
71
73
  let desktopWorkspace: DesktopWorkspace | null = null;
72
74
  let desktopRestartInProgress = false;
75
+ let desktopRemoteControlServer: RemoteControlServer | null = null;
73
76
 
74
77
  const windowRpcRegistry = createDesktopRpcRegistry<DesktopRpc>();
75
78
  const contextMenuRequestRpcs = new Map<string, DesktopRpc>();
@@ -100,6 +103,10 @@ function requireDesktopWorkspace(): DesktopWorkspace {
100
103
  return desktopWorkspace;
101
104
  }
102
105
 
106
+ function remoteFailure(code: string, message: string): RemoteControlResponse {
107
+ return { ok: false, error: { code, message } };
108
+ }
109
+
103
110
  const {
104
111
  forEachReadyWindowRpc,
105
112
  getRpcWindowKey,
@@ -130,6 +137,46 @@ const detachedWindowManager = new DesktopDetachedWindowManager<DesktopRpc>({
130
137
  stateBroadcaster: desktopStateBroadcaster,
131
138
  });
132
139
 
140
+ async function forwardRemoteControlRequest(request: RemoteControlRequest): Promise<RemoteControlResponse> {
141
+ const rpc = getWindowRpc(MAIN_WINDOW_RPC_KEY);
142
+ if (!rpc || !isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) {
143
+ return remoteFailure("remote_unavailable", "The main desktop window is not ready for remote control requests.");
144
+ }
145
+ try {
146
+ const response = await rpc.request["remote.request"]({
147
+ request: encodeRpcValue(request) as RemoteControlRequest,
148
+ });
149
+ return decodeRpcValue<RemoteControlResponse>(response);
150
+ } catch (error) {
151
+ return remoteFailure(
152
+ "remote_forward_error",
153
+ error instanceof Error ? error.message : String(error),
154
+ );
155
+ }
156
+ }
157
+
158
+ async function ensureDesktopRemoteControlServer(): Promise<void> {
159
+ if (desktopRemoteControlServer) return;
160
+ const config = requireConfig();
161
+ desktopRemoteControlServer = await startRemoteControlServer({
162
+ dataDir: config.dataDir,
163
+ appKind: "desktop",
164
+ handle: forwardRemoteControlRequest,
165
+ });
166
+ console.error("[remote] desktop control endpoint started", {
167
+ port: desktopRemoteControlServer.endpoint.port,
168
+ });
169
+ }
170
+
171
+ function stopDesktopRemoteControlServer(): void {
172
+ const server = desktopRemoteControlServer;
173
+ desktopRemoteControlServer = null;
174
+ if (!server) return;
175
+ void server.close().catch((error) => {
176
+ console.error("[remote] desktop control endpoint cleanup failed", summarizeError(error));
177
+ });
178
+ }
179
+
133
180
  function openMainWindowDevTools(): void {
134
181
  mainWindow?.webview.openDevTools();
135
182
  }
@@ -228,6 +275,7 @@ function disposeWindowScopedResources(windowKey: string): void {
228
275
  }
229
276
 
230
277
  function teardownServices(): void {
278
+ stopDesktopRemoteControlServer();
231
279
  capabilityBridge.disposeAll();
232
280
  services?.destroy();
233
281
  services = null;
@@ -306,7 +354,7 @@ async function initialize(
306
354
  rpc: DesktopRpc,
307
355
  payload: Record<string, unknown>,
308
356
  ) {
309
- return initializeDesktopBackend({
357
+ const init = await initializeDesktopBackend({
310
358
  getCurrentConfig: () => currentConfig,
311
359
  getCurrentServices: () => services,
312
360
  getDesktopSnapshot,
@@ -328,6 +376,12 @@ async function initialize(
328
376
  },
329
377
  syncConfigAccessors,
330
378
  });
379
+ if (init.windowKind === "main") {
380
+ void ensureDesktopRemoteControlServer().catch((error) => {
381
+ console.error("[remote] desktop control endpoint failed", summarizeError(error));
382
+ });
383
+ }
384
+ return init;
331
385
  }
332
386
 
333
387
  async function handleBackendRequest(
@@ -4,6 +4,7 @@ import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu"
4
4
  import type { AppConfig } from "../../../types/config";
5
5
  import type { UpdateProgress } from "../../../updater";
6
6
  import type { CapabilityManifest } from "../../../capabilities";
7
+ import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
7
8
 
8
9
  export const ELECTROBUN_CONTEXT_MENU_ACTION = "gloom.context-menu.select";
9
10
 
@@ -59,6 +60,10 @@ export interface DesktopRestartMessage {
59
60
  source?: string;
60
61
  }
61
62
 
63
+ export interface RemoteControlRequestMessage {
64
+ request: RemoteControlRequest;
65
+ }
66
+
62
67
  export interface ElectrobunDesktopRpcSchema {
63
68
  bun: {
64
69
  requests: {
@@ -72,7 +77,12 @@ export interface ElectrobunDesktopRpcSchema {
72
77
  };
73
78
  };
74
79
  webview: {
75
- requests: {};
80
+ requests: {
81
+ "remote.request": {
82
+ params: RemoteControlRequestMessage;
83
+ response: RemoteControlResponse;
84
+ };
85
+ };
76
86
  messages: {
77
87
  "context-menu.select": ContextMenuSelectMessage;
78
88
  "application-menu.select": ApplicationMenuSelectMessage;
@@ -9,10 +9,12 @@ import {
9
9
  type DesktopStateMessage,
10
10
  type DesktopThemePreviewMessage,
11
11
  type ElectrobunBackendInit,
12
+ type RemoteControlRequestMessage,
12
13
  type ElectrobunDesktopRpcSchema,
13
14
  type UpdateProgressMessage,
14
15
  } from "../shared/protocol";
15
16
  import { decodeRpcValue, encodeRpcValue } from "./rpc-codec";
17
+ import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
16
18
 
17
19
  type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void;
18
20
  type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void;
@@ -21,8 +23,10 @@ type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void;
21
23
  type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void;
22
24
  type UpdateProgressListener = (message: UpdateProgressMessage) => void;
23
25
  type CapabilityEventListener = (message: CapabilityEventMessage) => void;
26
+ type RemoteControlRequestHandler = (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
24
27
 
25
28
  let initSnapshot: ElectrobunBackendInit | null = null;
29
+ let remoteControlRequestHandler: RemoteControlRequestHandler | null = null;
26
30
  const contextMenuSelectListeners = new Map<string, Set<ContextMenuSelectListener>>();
27
31
  const applicationMenuSelectListeners = new Set<ApplicationMenuSelectListener>();
28
32
  const desktopStateListeners = new Set<DesktopStateListener>();
@@ -63,7 +67,21 @@ function subscribe<T>(
63
67
  const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
64
68
  maxRequestTime: 120_000,
65
69
  handlers: {
66
- requests: {},
70
+ requests: {
71
+ "remote.request": async (message: RemoteControlRequestMessage) => {
72
+ if (!remoteControlRequestHandler) {
73
+ return {
74
+ ok: false,
75
+ error: {
76
+ code: "remote_unavailable",
77
+ message: "The desktop app has not installed a remote control handler yet.",
78
+ },
79
+ };
80
+ }
81
+ const decoded = decodeRpcValue<RemoteControlRequest>(message.request);
82
+ return encodeRpcValue(await remoteControlRequestHandler(decoded)) as RemoteControlResponse;
83
+ },
84
+ },
67
85
  messages: {
68
86
  "context-menu.select": (message) => {
69
87
  dispatch(contextMenuSelectListeners, message.requestId, message);
@@ -140,6 +158,15 @@ export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null
140
158
  return initSnapshot;
141
159
  }
142
160
 
161
+ export function setElectrobunRemoteRequestHandler(handler: RemoteControlRequestHandler | null): () => void {
162
+ remoteControlRequestHandler = handler;
163
+ return () => {
164
+ if (remoteControlRequestHandler === handler) {
165
+ remoteControlRequestHandler = null;
166
+ }
167
+ };
168
+ }
169
+
143
170
  export function onCapabilityEvent(
144
171
  subscriptionId: string,
145
172
  listener: (message: CapabilityEventMessage) => void,
@@ -23,6 +23,7 @@ const COMMON_ALIAS_RULES: AliasRule[] = [
23
23
  ["./kitty/support", "components/chart/native/renderer-selection.ts", "native-stubs/chart/kitty-support.ts"],
24
24
  ["native/surface/manager", "native-stubs/chart/surface-manager.ts"],
25
25
  ["native/surface/sync", "native-stubs/chart/surface-sync.ts"],
26
+ ["./native-loader", "plugins/ibkr/gateway/service/index.ts", "native-stubs/ibkr-native-loader.ts"],
26
27
  ];
27
28
 
28
29
  export function electrobunViewPath(...parts: string[]): string {
@@ -135,6 +135,10 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
135
135
  callMouseHandler(propsRef.current.onMouseScroll, event, "scroll");
136
136
  };
137
137
 
138
+ const handleMouseOver = (event: MouseEvent) => {
139
+ callMouseHandler(propsRef.current.onMouseOver, event, "over");
140
+ };
141
+
138
142
  const handleMouseOut = (event: MouseEvent) => {
139
143
  pendingMoveRef.current = null;
140
144
  callMouseHandler(propsRef.current.onMouseOut, event, "out");
@@ -147,6 +151,7 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
147
151
  data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
148
152
  ref={elementRef}
149
153
  onMouseDown={handleMouseDown}
154
+ onMouseOver={handleMouseOver}
150
155
  onMouseMove={(event) => scheduleFrameMouseHandler(event, "move")}
151
156
  onMouseUp={(event) => callMouseHandler(propsRef.current.onMouseUp, event, "up")}
152
157
  onMouseOut={handleMouseOut}
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { hasDirectMouseHandler, mouseHandlers } from "./mouse";
3
+
4
+ function fakeMouseEvent() {
5
+ return {
6
+ clientX: 16,
7
+ clientY: 36,
8
+ button: 0,
9
+ detail: 0,
10
+ timeStamp: 1,
11
+ shiftKey: false,
12
+ altKey: false,
13
+ ctrlKey: false,
14
+ metaKey: false,
15
+ preventDefault() {},
16
+ stopPropagation() {},
17
+ };
18
+ }
19
+
20
+ describe("desktop web mouse host", () => {
21
+ test("forwards mouse over handlers", () => {
22
+ let cellX = -1;
23
+ let cellY = -1;
24
+ const handlers = mouseHandlers({
25
+ onMouseOver: (event: { x: number; y: number }) => {
26
+ cellX = event.x;
27
+ cellY = event.y;
28
+ },
29
+ });
30
+
31
+ handlers.onMouseOver?.(fakeMouseEvent() as never);
32
+
33
+ expect(cellX).toBe(2);
34
+ expect(cellY).toBe(2);
35
+ expect(hasDirectMouseHandler({ onMouseOver: () => {} })).toBe(true);
36
+ });
37
+ });
@@ -73,6 +73,7 @@ export function mouseHandlers(props: Record<string, unknown>) {
73
73
  );
74
74
  return {
75
75
  onMouseDown: wrap(props.onMouseDown),
76
+ onMouseOver: wrap(props.onMouseOver),
76
77
  onMouseMove: wrap(props.onMouseMove),
77
78
  onMouseUp: wrap(props.onMouseUp),
78
79
  onMouseOut: wrap(props.onMouseOut),
@@ -91,6 +92,7 @@ export function callCellMouseHandler(handler: unknown, event: CellMouseEvent): v
91
92
 
92
93
  export function hasDirectMouseHandler(props: Record<string, unknown>): boolean {
93
94
  return typeof props.onMouseDown === "function"
95
+ || typeof props.onMouseOver === "function"
94
96
  || typeof props.onMouseUp === "function"
95
97
  || typeof props.onMouseMove === "function"
96
98
  || typeof props.onMouseOut === "function"
@@ -5,7 +5,7 @@ import { App } from "../../../app";
5
5
  import { UiHostProvider } from "../../../ui/host";
6
6
  import { debugLog } from "../../../utils/debug-log";
7
7
  import { measurePerfAsync } from "../../../utils/perf-marks";
8
- import { backendRequest, initElectrobunBackend } from "./backend-rpc";
8
+ import { backendRequest, initElectrobunBackend, setElectrobunRemoteRequestHandler } from "./backend-rpc";
9
9
  import { installElectrobunAiHost } from "./ai-host";
10
10
  import { installElectrobunBrokerRemoteClient } from "./broker-remote-client";
11
11
  import { installElectrobunConfigStoreHost } from "./config-host";
@@ -89,6 +89,9 @@ async function boot() {
89
89
  const desktopWindowBridge = createDesktopWindowBridge(init.windowKind, init.paneId);
90
90
  const desktopApplicationMenuBridge = createApplicationMenuBridge();
91
91
  const webUiHost = createWebUiHost(init.desktopPlatform);
92
+ const remoteControlAdapter = init.windowKind === "main"
93
+ ? { registerHandler: setElectrobunRemoteRequestHandler }
94
+ : undefined;
92
95
  measurePerfAsync("startup.electrobun.root-render", async () => {
93
96
  root.render(
94
97
  <ElectrobunErrorBoundary>
@@ -102,6 +105,7 @@ async function boot() {
102
105
  desktopApplicationMenuBridge={desktopApplicationMenuBridge}
103
106
  desktopSnapshot={desktopSnapshot}
104
107
  desktopThemePreview={init.desktopThemePreview}
108
+ remoteControlAdapter={remoteControlAdapter}
105
109
  />
106
110
  </WebDialogHostProvider>
107
111
  </WebToastHostProvider>
@@ -0,0 +1,3 @@
1
+ export function loadNativeGatewayModule(): Promise<never> {
2
+ return Promise.reject(new Error("IBKR native gateway is unavailable in the desktop web renderer."));
3
+ }
@@ -0,0 +1,101 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createTestRenderer } from "@opentui/core/testing";
3
+ import { act } from "react";
4
+ import { ChartSurface, Text } from "../../ui";
5
+ import { getNativeSurfaceManager } from "../../components/chart/native/surface/manager";
6
+ import type { ChartRendererPreference } from "../../components/chart/core/types";
7
+ import { AppProvider } from "../../state/app/context";
8
+ import { createDefaultConfig } from "../../types/config";
9
+ import { createOpenTuiTestRoot } from "./test-utils";
10
+
11
+ let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
12
+ let root: ReturnType<typeof createOpenTuiTestRoot> | undefined;
13
+ const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
14
+
15
+ const bitmap = {
16
+ width: 4,
17
+ height: 4,
18
+ pixels: new Uint8Array(4 * 4 * 4).fill(255),
19
+ };
20
+
21
+ function setNativeRendererReady(): void {
22
+ (testSetup!.renderer as { _capabilities: unknown })._capabilities = { kitty_graphics: true };
23
+ (testSetup!.renderer as { _resolution: unknown })._resolution = { width: 800, height: 400 };
24
+ }
25
+
26
+ function surfaceCount(): number {
27
+ const manager = getNativeSurfaceManager(testSetup!.renderer as never) as unknown as {
28
+ surfaces: Map<string, unknown>;
29
+ };
30
+ return manager.surfaces.size;
31
+ }
32
+
33
+ async function flushFrames(): Promise<void> {
34
+ await act(async () => {
35
+ await testSetup!.renderOnce();
36
+ await testSetup!.renderOnce();
37
+ await new Promise((resolve) => setTimeout(resolve, 0));
38
+ await testSetup!.renderOnce();
39
+ });
40
+ }
41
+
42
+ function Harness({ preference }: { preference: ChartRendererPreference }) {
43
+ const config = createDefaultConfig(`/tmp/gloomberb-chart-surface-${preference}`);
44
+ config.chartPreferences.renderer = preference;
45
+
46
+ return (
47
+ <AppProvider config={config}>
48
+ <ChartSurface width={20} height={4} flexDirection="column" bitmaps={[bitmap]}>
49
+ <Text>fallback chart</Text>
50
+ </ChartSurface>
51
+ </AppProvider>
52
+ );
53
+ }
54
+
55
+ afterEach(() => {
56
+ if (root) {
57
+ act(() => {
58
+ root!.unmount();
59
+ });
60
+ root = undefined;
61
+ }
62
+ if (testSetup) {
63
+ testSetup.renderer.destroy();
64
+ testSetup = undefined;
65
+ }
66
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
67
+ });
68
+
69
+ describe("OpenTuiChartSurface", () => {
70
+ test("does not register kitty surfaces when the chart renderer is forced to braille", async () => {
71
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
72
+ testSetup = await createTestRenderer({ width: 40, height: 12 });
73
+ setNativeRendererReady();
74
+ root = createOpenTuiTestRoot(testSetup.renderer);
75
+
76
+ act(() => {
77
+ root!.render(<Harness preference="braille" />);
78
+ });
79
+
80
+ await flushFrames();
81
+
82
+ expect(testSetup.captureCharFrame()).toContain("fallback chart");
83
+ expect(surfaceCount()).toBe(0);
84
+ });
85
+
86
+ test("registers kitty surfaces when the chart renderer is forced to kitty and native graphics are ready", async () => {
87
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
88
+ testSetup = await createTestRenderer({ width: 40, height: 12 });
89
+ setNativeRendererReady();
90
+ root = createOpenTuiTestRoot(testSetup.renderer);
91
+
92
+ act(() => {
93
+ root!.render(<Harness preference="kitty" />);
94
+ });
95
+
96
+ await flushFrames();
97
+
98
+ expect(testSetup.captureCharFrame()).not.toContain("fallback chart");
99
+ expect(surfaceCount()).toBe(1);
100
+ });
101
+ });