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
@@ -5,10 +5,13 @@ import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
5
5
  import type { TickerFinancials } from "../../../../types/financials";
6
6
  import type { TickerRecord } from "../../../../types/ticker";
7
7
  import {
8
- VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS,
9
8
  VISIBLE_FINANCIAL_WARMUP_DELAY_MS,
9
+ VISIBLE_QUOTE_REFRESH_COOLDOWN_MS,
10
+ VISIBLE_QUOTE_STREAM_WATCHDOG_MS,
10
11
  VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT,
12
+ VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS,
11
13
  needsVisibleQuoteWarmup,
14
+ needsVisibleQuoteWatchdogRefresh,
12
15
  needsVisibleSnapshotWarmup,
13
16
  selectStreamTickers,
14
17
  visibleWarmupKey,
@@ -90,7 +93,7 @@ export function usePortfolioPaneStreaming({
90
93
  if (
91
94
  needsVisibleQuoteWarmup(financials, nowTimestamp)
92
95
  && !warmupInFlightRef.current.has(quoteKey)
93
- && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
96
+ && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
94
97
  ) {
95
98
  quoteQueue.push(ticker);
96
99
  continue;
@@ -100,7 +103,7 @@ export function usePortfolioPaneStreaming({
100
103
  if (
101
104
  needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
102
105
  && !warmupInFlightRef.current.has(snapshotKey)
103
- && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
106
+ && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS
104
107
  ) {
105
108
  snapshotQueue.push(ticker);
106
109
  }
@@ -130,7 +133,7 @@ export function usePortfolioPaneStreaming({
130
133
  try {
131
134
  await Promise.allSettled([
132
135
  quoteEntries.length > 0
133
- ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
136
+ ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true })
134
137
  : Promise.resolve(),
135
138
  snapshotEntries.length > 0
136
139
  ? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
@@ -155,5 +158,52 @@ export function usePortfolioPaneStreaming({
155
158
  };
156
159
  }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
157
160
 
161
+ useEffect(() => {
162
+ if (!appActive) return;
163
+ if (!focused) return;
164
+ if (!sharedCoordinator) return;
165
+
166
+ let cancelled = false;
167
+ const runWatchdog = async (): Promise<void> => {
168
+ const nowTimestamp = Date.now();
169
+ const quoteEntries = visibleFinancialTickers.flatMap((ticker) => {
170
+ const financials = financialsMap.get(ticker.metadata.ticker);
171
+ const key = visibleWarmupKey("quote", ticker);
172
+ if (
173
+ !needsVisibleQuoteWatchdogRefresh(financials, nowTimestamp)
174
+ || warmupInFlightRef.current.has(key)
175
+ || nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
176
+ ) {
177
+ return [];
178
+ }
179
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
180
+ if (!instrument) return [];
181
+ warmupInFlightRef.current.add(key);
182
+ warmupAttemptRef.current.set(key, nowTimestamp);
183
+ return [{ key, instrument }];
184
+ });
185
+ if (quoteEntries.length === 0) return;
186
+
187
+ try {
188
+ await sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true });
189
+ } catch {
190
+ // Best-effort watchdog for visible rows only.
191
+ } finally {
192
+ for (const entry of quoteEntries) {
193
+ warmupInFlightRef.current.delete(entry.key);
194
+ }
195
+ }
196
+ };
197
+
198
+ const intervalId = setInterval(() => {
199
+ if (!cancelled && mountedRef.current) void runWatchdog();
200
+ }, VISIBLE_QUOTE_STREAM_WATCHDOG_MS);
201
+
202
+ return () => {
203
+ cancelled = true;
204
+ clearInterval(intervalId);
205
+ };
206
+ }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
207
+
158
208
  useQuoteStreaming(streamTargets);
159
209
  }
@@ -3,11 +3,13 @@ import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type Col
3
3
  import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline/view";
4
4
 
5
5
  type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
6
+ export type PortfolioViewMode = "table" | "grid";
6
7
 
7
8
  export interface PortfolioPaneSettings {
8
9
  columnIds: string[];
9
10
  collectionScope: CollectionScope;
10
11
  visibleCollectionIds: string[];
12
+ viewMode: PortfolioViewMode;
11
13
  hideHeader: boolean;
12
14
  hideCash: boolean;
13
15
  }
@@ -95,11 +97,27 @@ const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
95
97
  description: "Choose exactly which collections this pane should show.",
96
98
  },
97
99
  ];
100
+ const VIEW_MODE_OPTIONS: PaneSettingOption[] = [
101
+ {
102
+ value: "table",
103
+ label: "Table",
104
+ description: "Show positions in the portfolio table.",
105
+ },
106
+ {
107
+ value: "grid",
108
+ label: "Grid",
109
+ description: "Show positions in the portfolio grid.",
110
+ },
111
+ ];
98
112
 
99
113
  function isCollectionScope(value: unknown): value is CollectionScope {
100
114
  return value === "all" || value === "portfolios" || value === "watchlists" || value === "custom";
101
115
  }
102
116
 
117
+ function isPortfolioViewMode(value: unknown): value is PortfolioViewMode {
118
+ return value === "table" || value === "grid";
119
+ }
120
+
103
121
  function filterCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
104
122
  switch (settings.collectionScope) {
105
123
  case "portfolios":
@@ -142,6 +160,7 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
142
160
  columnIds: columnIds.length > 0 ? columnIds : DEFAULT_PORTFOLIO_COLUMN_IDS,
143
161
  collectionScope: isCollectionScope(settings?.collectionScope) ? settings.collectionScope : "all",
144
162
  visibleCollectionIds,
163
+ viewMode: isPortfolioViewMode(settings?.viewMode) ? settings.viewMode : "table",
145
164
  hideHeader: settings?.hideHeader === true,
146
165
  hideCash: settings?.hideCash === true,
147
166
  };
@@ -218,9 +237,15 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
218
237
  .filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
219
238
  }
220
239
 
221
- export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: PortfolioPaneSettings): PaneSettingsDef {
240
+ export function buildPortfolioPaneSettingsDef(
241
+ config: AppConfig,
242
+ settings: PortfolioPaneSettings,
243
+ activeCollectionId?: string | null,
244
+ ): PaneSettingsDef {
222
245
  const collectionEntries = getCollectionEntries(config);
223
246
  const allCollectionOptions = resolveCollectionOptions(collectionEntries);
247
+ const activeCollectionIsPortfolio = !!activeCollectionId
248
+ && config.portfolios.some((portfolio) => portfolio.id === activeCollectionId);
224
249
 
225
250
  const fields: PaneSettingsDef["fields"] = [
226
251
  {
@@ -241,6 +266,15 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
241
266
  },
242
267
  ];
243
268
 
269
+ if (activeCollectionIsPortfolio) {
270
+ fields.push({
271
+ key: "viewMode",
272
+ label: "View",
273
+ type: "select",
274
+ options: VIEW_MODE_OPTIONS,
275
+ });
276
+ }
277
+
244
278
  if (settings.collectionScope === "custom") {
245
279
  fields.push({
246
280
  key: "visibleCollectionIds",
@@ -266,6 +300,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
266
300
  ...settings,
267
301
  columnIds: [...settings.columnIds],
268
302
  visibleCollectionIds: [...settings.visibleCollectionIds],
303
+ viewMode: settings.viewMode,
269
304
  },
270
305
  fields,
271
306
  };
@@ -1,4 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
2
5
  import { BarSizeSetting, ConnectionState, SecType, type ContractDetails } from "@stoqey/ib";
3
6
  import { Subject, of } from "rxjs";
4
7
  import {
@@ -246,6 +249,25 @@ test("remote gateway snapshots are side-effect-free", async () => {
246
249
  }
247
250
  });
248
251
 
252
+ test("native gateway loader bundles without a runtime ./native specifier", async () => {
253
+ const outdir = mkdtempSync(join(tmpdir(), "gloomberb-ibkr-service-"));
254
+ try {
255
+ const result = await Bun.build({
256
+ entrypoints: [join(import.meta.dir, "index.ts")],
257
+ outdir,
258
+ target: "bun",
259
+ });
260
+
261
+ expect(result.success).toBe(true);
262
+ const entry = readFileSync(join(outdir, "index.js"), "utf8");
263
+ expect(entry).toContain("src/plugins/ibkr/gateway/service/native.ts");
264
+ expect(entry).not.toContain('"./native"');
265
+ expect(entry).not.toContain("'./native'");
266
+ } finally {
267
+ rmSync(outdir, { recursive: true, force: true });
268
+ }
269
+ });
270
+
249
271
  describe("tick-by-tick quote updates", () => {
250
272
  const contract = {
251
273
  symbol: "AAPL",
@@ -17,18 +17,18 @@ import type {
17
17
  IbkrSnapshot,
18
18
  ResolvedIbkrGatewayConnection,
19
19
  } from "../types";
20
+ import {
21
+ loadNativeGatewayModule as loadDefaultNativeGatewayModule,
22
+ type NativeGatewayModule,
23
+ } from "./native-loader";
20
24
 
21
25
  export type {
22
26
  IbkrGatewayConfig,
23
27
  ResolvedIbkrGatewayConnection,
24
28
  } from "../types";
25
29
 
26
- type NativeGatewayService = any;
27
30
  type NativeGatewayManager = any;
28
- type NativeGatewayModule = {
29
- ibkrGatewayManager: NativeGatewayManager;
30
- setResolvedIbkrGatewayListener(listener: ResolvedGatewayListener | null): void;
31
- };
31
+ type NativeGatewayService = any;
32
32
  type Listener = () => void;
33
33
  type ResolvedGatewayListener = (
34
34
  instanceId: string | undefined,
@@ -47,9 +47,6 @@ let loadedNativeGatewayModule: NativeGatewayModule | null = null;
47
47
  let nativeGatewayModulePromise: Promise<NativeGatewayModule> | null = null;
48
48
  let nativeGatewayManager: NativeGatewayManager | null = null;
49
49
  let nativeGatewayModuleLoader: () => Promise<NativeGatewayModule>;
50
- const importNativeGatewayModule = new Function("specifier", "return import(specifier)") as (
51
- specifier: string,
52
- ) => Promise<NativeGatewayModule>;
53
50
 
54
51
  const snapshots = new Map<string, IbkrSnapshot>();
55
52
  const listeners = new Map<string, Set<Listener>>();
@@ -60,7 +57,7 @@ function getSnapshotEntry(instanceId?: string): IbkrSnapshot {
60
57
  }
61
58
 
62
59
  function defaultNativeGatewayModuleLoader(): Promise<NativeGatewayModule> {
63
- return importNativeGatewayModule("./native");
60
+ return loadDefaultNativeGatewayModule();
64
61
  }
65
62
 
66
63
  nativeGatewayModuleLoader = defaultNativeGatewayModuleLoader;
@@ -0,0 +1,12 @@
1
+ import type { ResolvedIbkrGatewayConnection } from "../types";
2
+
3
+ export type NativeGatewayModule = {
4
+ ibkrGatewayManager: any;
5
+ setResolvedIbkrGatewayListener(
6
+ listener: ((instanceId: string | undefined, connection: ResolvedIbkrGatewayConnection) => void | Promise<void>) | null,
7
+ ): void;
8
+ };
9
+
10
+ export function loadNativeGatewayModule(): Promise<NativeGatewayModule> {
11
+ return import("./native");
12
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import type { Dispatch } from "react";
3
+ import type { PluginRegistry } from "../plugins/registry";
4
+ import type { AppAction, AppState } from "../state/app/context";
5
+ import type { DesktopWindowBridge } from "../types/desktop-window";
6
+ import { createAppRemoteController } from "./controller";
7
+ import { useRemoteUiRegistry } from "./semantic-tree";
8
+ import type { RemoteControlRequest, RemoteControlResponse } from "./types";
9
+
10
+ export type RemoteControlHandler = (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
11
+
12
+ export interface RemoteControlAdapter {
13
+ startServer?(options: { dataDir: string; handle: RemoteControlHandler }): void | (() => void | Promise<void>);
14
+ registerHandler?(handler: RemoteControlHandler | null): void | (() => void);
15
+ }
16
+
17
+ interface RemoteControlHostProps {
18
+ adapter?: RemoteControlAdapter;
19
+ dispatch: Dispatch<AppAction>;
20
+ getState: () => AppState;
21
+ pluginRegistry: PluginRegistry;
22
+ desktopWindowBridge?: DesktopWindowBridge;
23
+ }
24
+
25
+ export function RemoteControlHost({
26
+ adapter,
27
+ dispatch,
28
+ getState,
29
+ pluginRegistry,
30
+ desktopWindowBridge,
31
+ }: RemoteControlHostProps) {
32
+ const uiRegistry = useRemoteUiRegistry();
33
+ const controller = useMemo(() => createAppRemoteController({
34
+ dispatch,
35
+ getState,
36
+ pluginRegistry,
37
+ uiRegistry,
38
+ desktopWindowBridge,
39
+ afterMutation: async () => {
40
+ await Promise.resolve();
41
+ await new Promise((resolve) => {
42
+ let settled = false;
43
+ const finish = () => {
44
+ if (settled) return;
45
+ settled = true;
46
+ resolve(undefined);
47
+ };
48
+ if (typeof requestAnimationFrame === "function") {
49
+ requestAnimationFrame(finish);
50
+ }
51
+ setTimeout(finish, 50);
52
+ });
53
+ await new Promise((resolve) => setTimeout(resolve, 0));
54
+ },
55
+ }), [desktopWindowBridge, dispatch, getState, pluginRegistry, uiRegistry]);
56
+ const dataDir = getState().config.dataDir;
57
+
58
+ useEffect(() => {
59
+ if (!adapter?.startServer) return;
60
+ const cleanup = adapter.startServer({ dataDir, handle: controller.handle });
61
+ if (!cleanup) return;
62
+ return () => {
63
+ void cleanup();
64
+ };
65
+ }, [adapter, controller, dataDir]);
66
+
67
+ useEffect(() => {
68
+ if (!adapter?.registerHandler) return;
69
+ const cleanup = adapter.registerHandler(controller.handle);
70
+ if (!cleanup) return;
71
+ return () => {
72
+ cleanup();
73
+ };
74
+ }, [adapter, controller]);
75
+
76
+ return null;
77
+ }
@@ -0,0 +1,34 @@
1
+ import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse } from "./types";
2
+ import { readRemoteEndpoint } from "./endpoint";
3
+
4
+ export interface SendRemoteControlRequestOptions {
5
+ dataDir: string;
6
+ appKind?: RemoteAppKind;
7
+ }
8
+
9
+ export async function sendRemoteControlRequest(
10
+ request: RemoteControlRequest,
11
+ options: SendRemoteControlRequestOptions,
12
+ ): Promise<RemoteControlResponse> {
13
+ const endpoint = await readRemoteEndpoint(options.dataDir, options.appKind);
14
+ const response = await fetch(`http://127.0.0.1:${endpoint.port}/rpc`, {
15
+ method: "POST",
16
+ headers: {
17
+ authorization: `Bearer ${endpoint.token}`,
18
+ "content-type": "application/json",
19
+ },
20
+ body: JSON.stringify(request),
21
+ });
22
+ const payload = await response.json() as RemoteControlResponse;
23
+ if (!response.ok && payload.ok !== false) {
24
+ return {
25
+ ok: false,
26
+ error: {
27
+ code: "remote_http_error",
28
+ message: `Remote control request failed with HTTP ${response.status}.`,
29
+ details: payload,
30
+ },
31
+ };
32
+ }
33
+ return payload;
34
+ }
@@ -0,0 +1,98 @@
1
+ import type { AppState } from "../state/app/context";
2
+ import type { RemoteUiNodeSnapshot } from "./types";
3
+
4
+ export function commandBarResultsFromNodes(nodes: RemoteUiNodeSnapshot[]) {
5
+ const directResults = nodes
6
+ .filter((node) => node.role === "command-bar-result")
7
+ .map((node) => {
8
+ const metadata = node.metadata ?? {};
9
+ const item = metadata.item && typeof metadata.item === "object"
10
+ ? metadata.item as Record<string, unknown>
11
+ : {};
12
+ return {
13
+ nodeId: node.id,
14
+ index: metadata.index,
15
+ label: node.label ?? item.label,
16
+ detail: item.detail,
17
+ category: item.category,
18
+ kind: item.kind,
19
+ right: item.right,
20
+ selected: metadata.selected === true,
21
+ disabled: node.disabled === true,
22
+ actions: node.actions,
23
+ itemId: item.id,
24
+ actionInput: undefined,
25
+ metadata,
26
+ };
27
+ });
28
+
29
+ const listResults = nodes.flatMap((node) => {
30
+ const metadata = node.metadata ?? {};
31
+ if (node.role !== "list" && node.role !== "command-bar-list") return [];
32
+ if (metadata.scope !== "command-bar") return [];
33
+ const rawItems = Array.isArray(metadata.items) ? metadata.items : [];
34
+ const selectedIndex = typeof metadata.selectedIndex === "number" ? metadata.selectedIndex : -1;
35
+ return rawItems
36
+ .map((entry, fallbackIndex) => (
37
+ entry && typeof entry === "object"
38
+ ? entry as Record<string, unknown>
39
+ : { label: String(entry), index: fallbackIndex }
40
+ ))
41
+ .map((item, fallbackIndex) => {
42
+ const index = typeof item.index === "number" ? item.index : fallbackIndex;
43
+ return {
44
+ nodeId: node.id,
45
+ index,
46
+ label: item.label,
47
+ detail: item.detail,
48
+ category: item.category ?? metadata.category,
49
+ kind: item.kind ?? metadata.itemKind,
50
+ right: item.right,
51
+ selected: selectedIndex === index,
52
+ disabled: item.disabled === true || node.disabled === true,
53
+ actions: node.actions,
54
+ itemId: item.id,
55
+ actionInput: {
56
+ index,
57
+ id: item.id,
58
+ label: item.label,
59
+ },
60
+ metadata: {
61
+ ...metadata,
62
+ item,
63
+ },
64
+ };
65
+ });
66
+ });
67
+
68
+ return [...directResults, ...listResults].sort((left, right) => {
69
+ const leftIndex = typeof left.index === "number" ? left.index : Number.MAX_SAFE_INTEGER;
70
+ const rightIndex = typeof right.index === "number" ? right.index : Number.MAX_SAFE_INTEGER;
71
+ return leftIndex - rightIndex;
72
+ });
73
+ }
74
+
75
+ export function isCommandBarInputNode(node: RemoteUiNodeSnapshot): boolean {
76
+ return (
77
+ (node.role === "input" || node.role === "command-bar-input")
78
+ && node.metadata?.scope === "command-bar"
79
+ );
80
+ }
81
+
82
+ function commandBarInputValue(nodes: RemoteUiNodeSnapshot[], fallback: string): string {
83
+ const focusedInput = nodes.find((node) => isCommandBarInputNode(node) && node.metadata?.focused === true);
84
+ const value = focusedInput?.metadata?.value;
85
+ return typeof value === "string" ? value : fallback;
86
+ }
87
+
88
+ export function commandBarSnapshot(state: AppState, nodes: RemoteUiNodeSnapshot[]) {
89
+ const results = commandBarResultsFromNodes(nodes);
90
+ return {
91
+ open: state.commandBarOpen,
92
+ query: commandBarInputValue(nodes, state.commandBarQuery),
93
+ stateQuery: state.commandBarQuery,
94
+ launchRequest: state.commandBarLaunchRequest,
95
+ selectedIndex: results.find((result) => result.selected)?.index ?? null,
96
+ results,
97
+ };
98
+ }
@@ -0,0 +1,111 @@
1
+ import { getDockedPaneIds } from "../plugins/pane-manager";
2
+ import type { AppState } from "../state/app/context";
3
+ import type { LayoutConfig, PaneInstanceConfig } from "../types/config";
4
+ import { revisionFor } from "./revision";
5
+ import type { RemoteControlResponse, RemoteIncludedState, RemoteStateInclude } from "./types";
6
+
7
+ export interface PatchTarget<T> {
8
+ value: T;
9
+ apply(value: T): Promise<void> | void;
10
+ }
11
+
12
+ export function ok<T>(data: T, rev?: string, state?: RemoteIncludedState): RemoteControlResponse<T> {
13
+ return {
14
+ ok: true,
15
+ data,
16
+ ...(rev ? { rev } : {}),
17
+ ...(state ? { state } : {}),
18
+ };
19
+ }
20
+
21
+ export function fail(code: string, error: unknown): RemoteControlResponse {
22
+ return {
23
+ ok: false,
24
+ error: {
25
+ code,
26
+ message: error instanceof Error ? error.message : String(error),
27
+ },
28
+ };
29
+ }
30
+
31
+ export function asRecord(input: unknown): Record<string, unknown> {
32
+ return input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : {};
33
+ }
34
+
35
+ export function stringInput(input: Record<string, unknown>, key: string): string {
36
+ const value = input[key];
37
+ if (typeof value !== "string" || value.length === 0) {
38
+ throw new Error(`${key} is required.`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ export function optionalString(input: Record<string, unknown>, key: string): string | undefined {
44
+ const value = input[key];
45
+ return typeof value === "string" && value.length > 0 ? value : undefined;
46
+ }
47
+
48
+ export function numberInput(input: Record<string, unknown>, key: string): number {
49
+ const value = input[key];
50
+ if (!Number.isInteger(value)) {
51
+ throw new Error(`${key} must be an integer.`);
52
+ }
53
+ return value as number;
54
+ }
55
+
56
+ export function optionalNumber(input: Record<string, unknown>, key: string): number | undefined {
57
+ const value = input[key];
58
+ return Number.isInteger(value) ? value as number : undefined;
59
+ }
60
+
61
+ export function activeLayoutRev(state: AppState): string {
62
+ return revisionFor({
63
+ layout: state.config.layout,
64
+ paneState: state.paneState,
65
+ focusedPaneId: state.focusedPaneId,
66
+ activePanel: state.activePanel,
67
+ });
68
+ }
69
+
70
+ export function panePlacement(layout: LayoutConfig, instanceId: string): "docked" | "floating" | "detached" | "hidden" {
71
+ if (getDockedPaneIds(layout).includes(instanceId)) return "docked";
72
+ if (layout.floating.some((entry) => entry.instanceId === instanceId)) return "floating";
73
+ if ((layout.detached ?? []).some((entry) => entry.instanceId === instanceId)) return "detached";
74
+ return "hidden";
75
+ }
76
+
77
+ export function paneSnapshot(state: AppState, pane: PaneInstanceConfig) {
78
+ return {
79
+ ...pane,
80
+ placement: panePlacement(state.config.layout, pane.instanceId),
81
+ focused: state.focusedPaneId === pane.instanceId,
82
+ runtimeState: state.paneState[pane.instanceId] ?? {},
83
+ };
84
+ }
85
+
86
+ export function mutationSummary(state: AppState, extra: Record<string, unknown> = {}) {
87
+ return {
88
+ ok: true,
89
+ rev: activeLayoutRev(state),
90
+ activeLayoutIndex: state.config.activeLayoutIndex,
91
+ activeLayoutName: state.config.layouts[state.config.activeLayoutIndex]?.name ?? null,
92
+ focusedPaneId: state.focusedPaneId,
93
+ commandBarOpen: state.commandBarOpen,
94
+ commandBarQuery: state.commandBarQuery,
95
+ dockedPaneIds: getDockedPaneIds(state.config.layout),
96
+ floatingPaneIds: state.config.layout.floating.map((entry) => entry.instanceId),
97
+ detachedPaneIds: (state.config.layout.detached ?? []).map((entry) => entry.instanceId),
98
+ ...extra,
99
+ };
100
+ }
101
+
102
+ export function normalizeIncludes(
103
+ include: RemoteStateInclude[] | undefined,
104
+ defaults: RemoteStateInclude[] = [],
105
+ ): RemoteStateInclude[] {
106
+ const raw = include ?? defaults;
107
+ if (raw.includes("all")) {
108
+ return ["app", "layout", "panes", "commandBar", "ui", "schema", "help"];
109
+ }
110
+ return [...new Set(raw)];
111
+ }