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
@@ -0,0 +1,197 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import {
3
+ buildMetricTreemapNavigationTiles,
4
+ findMetricTreemapNeighbor,
5
+ MetricTreemapSurface,
6
+ type MetricTreemapDirection,
7
+ type MetricTreemapItem,
8
+ } from "../../../components";
9
+ import { useShortcut } from "../../../react/input";
10
+ import type { ColumnConfig } from "../../../types/config";
11
+ import type { TickerFinancials } from "../../../types/financials";
12
+ import type { TickerRecord } from "../../../types/ticker";
13
+ import { useUiCapabilities } from "../../../ui";
14
+ import { isPlainKey } from "../../../utils/keyboard";
15
+ import { getColumnValue, getSortValue, type ColumnContext } from "./metrics";
16
+ import { getPortfolioPositionMetrics } from "./position-metrics";
17
+
18
+ const MARKET_CAP_COLUMN: ColumnConfig = { id: "market_cap", label: "Mkt Cap", width: 10, align: "right" };
19
+ const MARKET_VALUE_COLUMN: ColumnConfig = { id: "mkt_value", label: "Mkt Value", width: 10, align: "right" };
20
+ const CHANGE_PCT_COLUMN: ColumnConfig = { id: "change_pct", label: "Change", width: 8, align: "right" };
21
+ const DAY_PNL_COLUMN: ColumnConfig = { id: "day_pnl", label: "Day P&L", width: 10, align: "right" };
22
+ const VOLUME_COLUMN: ColumnConfig = { id: "volume", label: "Volume", width: 9, align: "right" };
23
+ const WEIGHT_COLUMN: ColumnConfig = { id: "weight", label: "Weight", width: 8, align: "right" };
24
+
25
+ function numericValue(value: number | string | null): number | null {
26
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
27
+ }
28
+
29
+ function quoteCurrency(ticker: TickerRecord, financials: TickerFinancials | undefined): string {
30
+ return financials?.quote?.currency || ticker.metadata.currency || "USD";
31
+ }
32
+
33
+ function positionAdjustedChangePercent(
34
+ ticker: TickerRecord,
35
+ financials: TickerFinancials | undefined,
36
+ context: ColumnContext,
37
+ isPortfolioTab: boolean,
38
+ ): number | null {
39
+ const changePercent = numericValue(getSortValue(CHANGE_PCT_COLUMN, ticker, financials, context));
40
+ if (changePercent == null || !isPortfolioTab) return changePercent;
41
+ const positionMetrics = getPortfolioPositionMetrics(ticker, context.activeTab, quoteCurrency(ticker, financials));
42
+ return positionMetrics.totalPriceUnits < 0 ? -changePercent : changePercent;
43
+ }
44
+
45
+ function buildPortfolioGridItems(
46
+ tickers: TickerRecord[],
47
+ financialsMap: Map<string, TickerFinancials>,
48
+ context: ColumnContext,
49
+ isPortfolioTab: boolean,
50
+ ): Array<MetricTreemapItem<TickerRecord>> {
51
+ return tickers.map((ticker) => {
52
+ const symbol = ticker.metadata.ticker;
53
+ const financials = financialsMap.get(symbol);
54
+ const weightColumn = isPortfolioTab ? MARKET_VALUE_COLUMN : MARKET_CAP_COLUMN;
55
+ const primaryColumn = isPortfolioTab ? MARKET_VALUE_COLUMN : MARKET_CAP_COLUMN;
56
+ const fallbackColumn = isPortfolioTab ? MARKET_CAP_COLUMN : VOLUME_COLUMN;
57
+ const weight = numericValue(getSortValue(weightColumn, ticker, financials, context))
58
+ ?? numericValue(getSortValue(fallbackColumn, ticker, financials, context))
59
+ ?? 1;
60
+ const primary = getColumnValue(primaryColumn, ticker, financials, context).text;
61
+ const change = getColumnValue(CHANGE_PCT_COLUMN, ticker, financials, context).text;
62
+ const secondary = isPortfolioTab
63
+ ? `${getColumnValue(DAY_PNL_COLUMN, ticker, financials, context).text} ${change}`
64
+ : change;
65
+ const tertiary = isPortfolioTab
66
+ ? getColumnValue(WEIGHT_COLUMN, ticker, financials, context).text
67
+ : getColumnValue(VOLUME_COLUMN, ticker, financials, context).text;
68
+
69
+ return {
70
+ id: symbol,
71
+ label: symbol,
72
+ weight: Math.max(1, Math.abs(weight)),
73
+ colorValue: positionAdjustedChangePercent(ticker, financials, context, isPortfolioTab),
74
+ primaryText: primary,
75
+ secondaryText: secondary,
76
+ tertiaryText: tertiary,
77
+ data: ticker,
78
+ };
79
+ });
80
+ }
81
+
82
+ export function PortfolioGrid({
83
+ sortedTickers,
84
+ financialsMap,
85
+ columnContext,
86
+ isPortfolioTab,
87
+ cursorSymbol,
88
+ setCursorSymbol,
89
+ onRowActivate,
90
+ onToggleViewMode,
91
+ focused,
92
+ width,
93
+ height,
94
+ }: {
95
+ sortedTickers: TickerRecord[];
96
+ financialsMap: Map<string, TickerFinancials>;
97
+ columnContext: ColumnContext;
98
+ isPortfolioTab: boolean;
99
+ cursorSymbol: string | null;
100
+ setCursorSymbol: (symbol: string) => void;
101
+ onRowActivate: (ticker: TickerRecord) => void;
102
+ onToggleViewMode: () => void;
103
+ focused?: boolean;
104
+ width: number;
105
+ height: number;
106
+ }) {
107
+ const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
108
+ const chartWidth = Math.max(1, width - 2);
109
+ const cellAspect = Math.max(0.5, Math.min(4, cellHeightPx / Math.max(1, cellWidthPx)));
110
+ const items = useMemo(
111
+ () => buildPortfolioGridItems(sortedTickers, financialsMap, columnContext, isPortfolioTab),
112
+ [columnContext, financialsMap, isPortfolioTab, sortedTickers],
113
+ );
114
+ const navigationTiles = useMemo(
115
+ () => buildMetricTreemapNavigationTiles(items, chartWidth, height, cellAspect, nativePaneChrome ? "float" : "integer"),
116
+ [cellAspect, chartWidth, height, items, nativePaneChrome],
117
+ );
118
+ const selectedIdx = cursorSymbol
119
+ ? sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol)
120
+ : -1;
121
+ const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedTickers.length > 0 ? 0 : -1);
122
+
123
+ const selectIndex = useCallback((index: number) => {
124
+ const ticker = sortedTickers[index];
125
+ if (ticker) setCursorSymbol(ticker.metadata.ticker);
126
+ }, [setCursorSymbol, sortedTickers]);
127
+
128
+ const selectNeighbor = useCallback((direction: MetricTreemapDirection) => {
129
+ const target = findMetricTreemapNeighbor(navigationTiles, cursorSymbol, direction);
130
+ if (target) setCursorSymbol(target.item.data.metadata.ticker);
131
+ }, [cursorSymbol, navigationTiles, setCursorSymbol]);
132
+
133
+ useShortcut((event) => {
134
+ if (!focused) return;
135
+ if (isPlainKey(event, "s")) {
136
+ event.preventDefault();
137
+ event.stopPropagation();
138
+ onToggleViewMode();
139
+ return;
140
+ }
141
+ if (isPlainKey(event, "j")) {
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ selectIndex(Math.min((activeIdx >= 0 ? activeIdx : 0) + 1, sortedTickers.length - 1));
145
+ return;
146
+ }
147
+ if (isPlainKey(event, "k")) {
148
+ event.preventDefault();
149
+ event.stopPropagation();
150
+ selectIndex(Math.max((activeIdx >= 0 ? activeIdx : 0) - 1, 0));
151
+ return;
152
+ }
153
+ if (isPlainKey(event, "left", "h")) {
154
+ event.preventDefault();
155
+ event.stopPropagation();
156
+ selectNeighbor("left");
157
+ return;
158
+ }
159
+ if (isPlainKey(event, "right", "l")) {
160
+ event.preventDefault();
161
+ event.stopPropagation();
162
+ selectNeighbor("right");
163
+ return;
164
+ }
165
+ if (isPlainKey(event, "up")) {
166
+ event.preventDefault();
167
+ event.stopPropagation();
168
+ selectNeighbor("up");
169
+ return;
170
+ }
171
+ if (isPlainKey(event, "down")) {
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ selectNeighbor("down");
175
+ return;
176
+ }
177
+ if (isPlainKey(event, "enter", "return")) {
178
+ const ticker = sortedTickers[activeIdx];
179
+ if (!ticker) return;
180
+ event.preventDefault();
181
+ event.stopPropagation();
182
+ onRowActivate(ticker);
183
+ }
184
+ }, { enabled: focused });
185
+
186
+ return (
187
+ <MetricTreemapSurface
188
+ items={items}
189
+ width={width}
190
+ height={height}
191
+ selectedId={cursorSymbol}
192
+ onSelect={(item) => setCursorSymbol(item.data.metadata.ticker)}
193
+ onActivate={(item) => onRowActivate(item.data)}
194
+ emptyStateTitle={isPortfolioTab ? "No portfolio positions" : "No watchlist tickers"}
195
+ />
196
+ );
197
+ }
@@ -687,6 +687,47 @@ describe("PortfolioListPane cash and margin UI", () => {
687
687
  expect(frame).toContain("25.00%");
688
688
  });
689
689
 
690
+ test("renders portfolio grid from portfolio table values and opens the selected ticker", async () => {
691
+ const portfolioId = "broker:ibkr-flex:DU12345";
692
+ const config = createPortfolioConfig(portfolioId, [createBrokerInstance("flex")]);
693
+ const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
694
+ const runtime = createTestPluginRuntime({
695
+ pinTicker: (symbol, options) => {
696
+ pinned.push({ symbol, options });
697
+ },
698
+ });
699
+
700
+ testSetup = await testRender(
701
+ <PortfolioHarness
702
+ config={config}
703
+ collectionId={portfolioId}
704
+ runtime={runtime}
705
+ stateMutator={(state) => {
706
+ state.paneState[TEST_PANE_ID] = {
707
+ ...state.paneState[TEST_PANE_ID],
708
+ viewMode: "grid",
709
+ };
710
+ }}
711
+ />,
712
+ { width: 100, height: 12 },
713
+ );
714
+
715
+ await flushFrame();
716
+
717
+ const frame = testSetup.captureCharFrame();
718
+ expect(frame).toContain("Grid");
719
+ expect(frame).toContain("AAPL");
720
+ expect(frame).toContain("1.3k");
721
+ expect(frame).toContain("+4.17%");
722
+
723
+ await act(async () => {
724
+ testSetup!.mockInput.pressEnter();
725
+ await testSetup!.renderOnce();
726
+ });
727
+
728
+ expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: TICKER_RESEARCH_PANE_ID } }]);
729
+ });
730
+
690
731
  test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
691
732
  const portfolioId = "broker:ibkr-flex:DU12345";
692
733
  const optionTicker = "SPY 260619C00500000";
@@ -36,6 +36,7 @@ import {
36
36
  } from "../settings";
37
37
  import { useQuoteFlashMap } from "../../../../components/quote-flash";
38
38
  import { PortfolioTickerTable } from "../table";
39
+ import { PortfolioGrid } from "../grid";
39
40
  import { useThrottledCursorSymbol } from "../use-throttled-cursor-symbol";
40
41
  import { isManualPortfolio } from "../mutations";
41
42
  import { QuickAddTickerInput, type QuickAddCollectionKind } from "../quick-add";
@@ -49,6 +50,8 @@ import {
49
50
  import { usePortfolioPaneStreaming } from "./streaming";
50
51
  import { usePortfolioSupplementalData } from "./supplemental";
51
52
 
53
+ type PortfolioViewMode = "table" | "grid";
54
+
52
55
  export function PortfolioListPane({ focused, width, height }: PaneProps) {
53
56
  const { pinTicker } = usePluginTickerActions();
54
57
  const paneInstance = usePaneInstance();
@@ -65,6 +68,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
65
68
  const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
66
69
  const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
67
70
  const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
71
+ const [viewMode, setViewMode] = usePaneStateValue<PortfolioViewMode>("viewMode", "table");
68
72
 
69
73
  const [now, setNow] = useState(Date.now());
70
74
  const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
@@ -174,7 +178,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
174
178
  ? (cashDrawerExpanded ? Math.min(6, Math.max(3, 2 + accountState.visibleCashBalances.length)) : 1)
175
179
  : 0;
176
180
  const showCollectionTabs = visibleCollections.length > 1;
177
- const headerHeight = showCollectionTabs ? 1 : 0;
181
+ const viewTabsHeight = 1;
182
+ const headerHeight = viewTabsHeight + (showCollectionTabs ? 1 : 0);
178
183
  const drawerHeight = showCashDrawer
179
184
  ? Math.min(requestedDrawerHeight, Math.max(1, height - (headerHeight + 2)))
180
185
  : 0;
@@ -214,6 +219,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
214
219
  pinTicker(symbol, { floating: true, paneType: TICKER_RESEARCH_PANE_ID });
215
220
  }, [pinTicker]);
216
221
 
222
+ const toggleViewMode = useCallback(() => {
223
+ setViewMode((current) => current === "table" ? "grid" : "table");
224
+ }, [setViewMode]);
225
+
217
226
  const handleRowActivate = useCallback((ticker: TickerRecord) => {
218
227
  flushCursorSymbol(ticker.metadata.ticker);
219
228
  openTickerFloating(ticker.metadata.ticker);
@@ -245,6 +254,13 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
245
254
  return true;
246
255
  }
247
256
 
257
+ if (key === "s") {
258
+ event.preventDefault?.();
259
+ event.stopPropagation?.();
260
+ toggleViewMode();
261
+ return true;
262
+ }
263
+
248
264
  return false;
249
265
  }, [
250
266
  cashDrawerExpanded,
@@ -254,6 +270,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
254
270
  setCashDrawerExpanded,
255
271
  showCashDrawer,
256
272
  sortedTickers,
273
+ toggleViewMode,
257
274
  ]);
258
275
 
259
276
  useEffect(() => {
@@ -281,12 +298,18 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
281
298
  }
282
299
  }, [cursorSymbol, setCursorSymbol, sortedTickers]);
283
300
 
301
+ const effectiveStreamWindow = useMemo(() => (
302
+ viewMode === "grid"
303
+ ? { start: 0, end: Math.min(sortedTickers.length, 96) }
304
+ : streamWindow
305
+ ), [sortedTickers.length, streamWindow, viewMode]);
306
+
284
307
  usePortfolioPaneStreaming({
285
308
  appActive,
286
309
  focused,
287
310
  sortedTickers,
288
311
  cursorSymbol,
289
- streamWindow,
312
+ streamWindow: effectiveStreamWindow,
290
313
  isPortfolioTab,
291
314
  financialsMap,
292
315
  visibleWarmupRequirements,
@@ -341,7 +364,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
341
364
  }, [activeCollectionId, config, currentPortfolio]);
342
365
  const showQuickAdd = !!(activeCollectionId && activeCollectionEntry && quickAddCollectionKind);
343
366
  const quickAddHeight = showQuickAdd ? 1 : 0;
344
- const tableHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
367
+ const contentHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
345
368
  const quickAddRow = activeCollectionId && activeCollectionEntry && quickAddCollectionKind ? (
346
369
  <QuickAddTickerInput
347
370
  collectionId={activeCollectionId}
@@ -357,6 +380,23 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
357
380
  return (
358
381
  <Box flexDirection="column" width={width} height={height}>
359
382
  <Box flexDirection="column" height={headerHeight}>
383
+ <Box flexDirection="row" height={1}>
384
+ <Box flexShrink={1} overflow="hidden">
385
+ <Tabs
386
+ tabs={[
387
+ { label: "Table", value: "table" },
388
+ { label: "Grid", value: "grid" },
389
+ ]}
390
+ activeValue={viewMode}
391
+ onSelect={(value) => setViewMode(value as PortfolioViewMode)}
392
+ compact
393
+ variant="bare"
394
+ focused={focused && !quickAddFocused}
395
+ keyboardNavigation={false}
396
+ />
397
+ </Box>
398
+ </Box>
399
+
360
400
  {showCollectionTabs && (
361
401
  <Box flexDirection="row" height={1}>
362
402
  <Box flexShrink={1} overflow="hidden">
@@ -372,25 +412,41 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
372
412
  )}
373
413
  </Box>
374
414
 
375
- <PortfolioTickerTable
376
- columns={columns}
377
- focused={focused && !quickAddFocused}
378
- sortColumnId={activeSort.columnId}
379
- sortDirection={activeSort.direction}
380
- onHeaderClick={handleHeaderClick}
381
- sortedTickers={sortedTickers}
382
- cursorSymbol={cursorSymbol}
383
- setCursorSymbol={setCursorSymbol}
384
- financialsMap={financialsMap}
385
- columnContext={columnContext}
386
- flashSymbols={flashSymbols}
387
- onRootKeyDown={handleTableKeyDown}
388
- onVisibleRangeChange={handleVisibleRangeChange}
389
- visibleRangeBuffer={3}
390
- resetScrollKey={activeCollectionId}
391
- onRowActivate={handleRowActivate}
392
- rootHeight={tableHeight}
393
- />
415
+ {viewMode === "table" ? (
416
+ <PortfolioTickerTable
417
+ columns={columns}
418
+ focused={focused && !quickAddFocused}
419
+ sortColumnId={activeSort.columnId}
420
+ sortDirection={activeSort.direction}
421
+ onHeaderClick={handleHeaderClick}
422
+ sortedTickers={sortedTickers}
423
+ cursorSymbol={cursorSymbol}
424
+ setCursorSymbol={setCursorSymbol}
425
+ financialsMap={financialsMap}
426
+ columnContext={columnContext}
427
+ flashSymbols={flashSymbols}
428
+ onRootKeyDown={handleTableKeyDown}
429
+ onVisibleRangeChange={handleVisibleRangeChange}
430
+ visibleRangeBuffer={3}
431
+ resetScrollKey={activeCollectionId}
432
+ onRowActivate={handleRowActivate}
433
+ rootHeight={contentHeight}
434
+ />
435
+ ) : (
436
+ <PortfolioGrid
437
+ sortedTickers={sortedTickers}
438
+ financialsMap={financialsMap}
439
+ columnContext={columnContext}
440
+ isPortfolioTab={isPortfolioTab}
441
+ cursorSymbol={cursorSymbol}
442
+ setCursorSymbol={(symbol) => setCursorSymbol(symbol)}
443
+ onRowActivate={handleRowActivate}
444
+ onToggleViewMode={toggleViewMode}
445
+ focused={focused && !quickAddFocused}
446
+ width={width}
447
+ height={contentHeight}
448
+ />
449
+ )}
394
450
 
395
451
  {quickAddRow}
396
452
 
@@ -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
+ }