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,365 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatch } from "react";
3
+ import { appReducer, createInitialState, type AppAction, type AppState } from "../core/state/app/state";
4
+ import type { PluginRegistry } from "../plugins/registry";
5
+ import { createDefaultConfig } from "../types/config";
6
+ import { createAppRemoteController } from "./controller";
7
+ import type { RemoteControlSchema, RemoteUiNodeSnapshot } from "./types";
8
+ import type { RemoteUiRegistry } from "./semantic-tree";
9
+
10
+ function createRegistryHarness() {
11
+ let state = createInitialState({
12
+ ...createDefaultConfig("/tmp/gloom-remote-controller"),
13
+ onboardingComplete: true,
14
+ });
15
+ const actions: AppAction[] = [];
16
+ const dispatch: Dispatch<AppAction> = (action) => {
17
+ actions.push(action);
18
+ state = appReducer(state, action);
19
+ };
20
+ const registry = {
21
+ panes: new Map(),
22
+ paneTemplates: new Map(),
23
+ commands: new Map(),
24
+ capabilities: {
25
+ manifests: () => [],
26
+ invoke: async () => ({ invoked: true }),
27
+ },
28
+ resolvePaneSettings: () => null,
29
+ showPane: () => {},
30
+ focusPane: () => {},
31
+ hidePane: () => {},
32
+ createPaneFromTemplateAsyncFn: async () => {},
33
+ navigateTicker: () => {},
34
+ pinTicker: () => {},
35
+ selectTicker: () => {},
36
+ switchTab: () => {},
37
+ getTermSizeFn: () => ({ width: 120, height: 40 }),
38
+ updateLayoutFn: (layout: AppState["config"]["layout"]) => {
39
+ dispatch({ type: "UPDATE_LAYOUT", layout });
40
+ },
41
+ applyPaneSettingValueFn: async () => {},
42
+ notify: () => {},
43
+ } as unknown as PluginRegistry;
44
+ let uiNodes: RemoteUiNodeSnapshot[] = [{ id: "ui:test", role: "button", label: "Test", actions: ["press"] }];
45
+ const invokedUiActions: Array<{ nodeId: string; action: string; input?: unknown }> = [];
46
+ const uiRegistry: RemoteUiRegistry = {
47
+ register: () => {},
48
+ unregister: () => {},
49
+ snapshot: () => uiNodes,
50
+ invoke: async (nodeId, action, input) => {
51
+ invokedUiActions.push({ nodeId, action, input });
52
+ return { nodeId, action, input };
53
+ },
54
+ };
55
+ const controller = createAppRemoteController({
56
+ dispatch,
57
+ getState: () => state,
58
+ pluginRegistry: registry,
59
+ uiRegistry,
60
+ });
61
+ return {
62
+ actions,
63
+ controller,
64
+ getState: () => state,
65
+ invokedUiActions,
66
+ setUiNodes: (nodes: RemoteUiNodeSnapshot[]) => {
67
+ uiNodes = nodes;
68
+ },
69
+ };
70
+ }
71
+
72
+ describe("createAppRemoteController", () => {
73
+ test("exposes schema, app snapshot, and semantic UI tree", async () => {
74
+ const { controller } = createRegistryHarness();
75
+
76
+ const schema = await controller.handle({ type: "schema" });
77
+ expect(schema.ok).toBe(true);
78
+ if (schema.ok) {
79
+ const data = schema.data as RemoteControlSchema;
80
+ expect(data.resources.some((resource) => resource.uri === "ui://tree")).toBe(true);
81
+ expect(data.operations.some((operation) => operation.id === "ui.invoke")).toBe(true);
82
+ expect(data.help).toMatchObject({
83
+ title: "Gloomberb remote control guide",
84
+ });
85
+ }
86
+
87
+ const help = await controller.handle({ type: "help" });
88
+ expect(help.ok).toBe(true);
89
+ if (help.ok) {
90
+ expect(help.data).toMatchObject({
91
+ batching: expect.any(Object),
92
+ });
93
+ }
94
+
95
+ const snapshot = await controller.handle({ type: "get", resource: "app://snapshot" });
96
+ expect(snapshot.ok).toBe(true);
97
+ if (snapshot.ok) {
98
+ const data = snapshot.data as { ui: unknown[] };
99
+ expect(data.ui).toEqual([{ id: "ui:test", role: "button", label: "Test", actions: ["press"] }]);
100
+ expect(typeof snapshot.rev).toBe("string");
101
+ }
102
+ });
103
+
104
+ test("dispatches semantic app operations", async () => {
105
+ const { actions, controller, getState } = createRegistryHarness();
106
+
107
+ const response = await controller.handle({
108
+ type: "call",
109
+ operation: "app.openCommandBar",
110
+ input: { query: "NVDA" },
111
+ });
112
+
113
+ expect(response.ok).toBe(true);
114
+ expect(actions).toContainEqual({ type: "SET_COMMAND_BAR", open: true, query: "NVDA" });
115
+ expect(getState().commandBarOpen).toBe(true);
116
+ expect(getState().commandBarQuery).toBe("NVDA");
117
+ if (response.ok) {
118
+ expect(response.state?.commandBar).toMatchObject({
119
+ open: true,
120
+ stateQuery: "NVDA",
121
+ });
122
+ }
123
+ });
124
+
125
+ test("opens ticker search without requiring command-bar prefix syntax", async () => {
126
+ const { actions, controller, getState } = createRegistryHarness();
127
+
128
+ const response = await controller.handle({
129
+ type: "call",
130
+ operation: "app.search",
131
+ input: { mode: "ticker", query: "google" },
132
+ });
133
+
134
+ expect(response.ok).toBe(true);
135
+ expect(actions).toContainEqual({
136
+ type: "SET_COMMAND_BAR",
137
+ open: true,
138
+ launch: { kind: "ticker-search", query: "google" },
139
+ });
140
+ expect(getState().commandBarOpen).toBe(true);
141
+ expect(getState().commandBarLaunchRequest).toMatchObject({
142
+ kind: "ticker-search",
143
+ query: "google",
144
+ });
145
+ });
146
+
147
+ test("exposes and activates semantic command-bar results", async () => {
148
+ const { controller, invokedUiActions, setUiNodes } = createRegistryHarness();
149
+ setUiNodes([
150
+ {
151
+ id: "ui:input",
152
+ role: "input",
153
+ actions: ["setValue"],
154
+ metadata: { value: "T google", focused: true },
155
+ },
156
+ {
157
+ id: "ui:goog",
158
+ role: "command-bar-result",
159
+ label: "GOOG",
160
+ actions: ["activate"],
161
+ metadata: {
162
+ index: 1,
163
+ selected: false,
164
+ item: {
165
+ id: "ticker:GOOG",
166
+ label: "GOOG",
167
+ detail: "Alphabet Inc.",
168
+ category: "Primary Listing",
169
+ kind: "ticker",
170
+ right: "Equity NASDAQ",
171
+ },
172
+ },
173
+ },
174
+ ]);
175
+
176
+ const results = await controller.handle({ type: "get", resource: "app://command-bar/results" });
177
+ expect(results.ok).toBe(true);
178
+ if (results.ok) {
179
+ expect(results.data).toMatchObject([
180
+ {
181
+ nodeId: "ui:goog",
182
+ index: 1,
183
+ label: "GOOG",
184
+ kind: "ticker",
185
+ right: "Equity NASDAQ",
186
+ },
187
+ ]);
188
+ }
189
+
190
+ const activated = await controller.handle({
191
+ type: "call",
192
+ operation: "commandBar.activateResult",
193
+ input: { index: 1 },
194
+ });
195
+ expect(activated.ok).toBe(true);
196
+ expect(invokedUiActions).toContainEqual({ nodeId: "ui:goog", action: "activate", input: undefined });
197
+ });
198
+
199
+ test("scopes command-bar query reads to the command-bar input", async () => {
200
+ const { controller, setUiNodes } = createRegistryHarness();
201
+ setUiNodes([
202
+ {
203
+ id: "ui:pane-input",
204
+ role: "input",
205
+ actions: ["setValue"],
206
+ metadata: { value: "pane search", focused: true },
207
+ },
208
+ {
209
+ id: "ui:command-input",
210
+ role: "input",
211
+ actions: ["setValue"],
212
+ metadata: { value: "theme", focused: true, scope: "command-bar" },
213
+ },
214
+ ]);
215
+
216
+ const response = await controller.handle({ type: "get", resource: "app://command-bar" });
217
+ expect(response.ok).toBe(true);
218
+ if (response.ok) {
219
+ expect((response.data as { query: string }).query).toBe("theme");
220
+ }
221
+ });
222
+
223
+ test("exposes shared list items as command-bar results", async () => {
224
+ const { controller, invokedUiActions, setUiNodes } = createRegistryHarness();
225
+ setUiNodes([
226
+ {
227
+ id: "ui:theme-list",
228
+ role: "list",
229
+ label: "Theme picker",
230
+ actions: ["activate", "select"],
231
+ metadata: {
232
+ scope: "command-bar",
233
+ selectedIndex: 1,
234
+ itemKind: "theme",
235
+ items: [
236
+ { index: 0, id: "github-dark", label: "GitHub Dark", kind: "theme", category: "Themes" },
237
+ { index: 1, id: "github-light", label: "GitHub Light", kind: "theme", category: "Themes", current: true },
238
+ ],
239
+ },
240
+ },
241
+ ]);
242
+
243
+ const results = await controller.handle({ type: "get", resource: "app://command-bar/results" });
244
+ expect(results.ok).toBe(true);
245
+ if (results.ok) {
246
+ expect(results.data).toMatchObject([
247
+ { nodeId: "ui:theme-list", index: 0, label: "GitHub Dark", kind: "theme", itemId: "github-dark" },
248
+ { nodeId: "ui:theme-list", index: 1, label: "GitHub Light", kind: "theme", itemId: "github-light", selected: true },
249
+ ]);
250
+ }
251
+
252
+ const activated = await controller.handle({
253
+ type: "call",
254
+ operation: "commandBar.activateResult",
255
+ input: { label: "GitHub Light" },
256
+ });
257
+ expect(activated.ok).toBe(true);
258
+ expect(invokedUiActions).toContainEqual({
259
+ nodeId: "ui:theme-list",
260
+ action: "activate",
261
+ input: { index: 1, id: "github-light", label: "GitHub Light" },
262
+ });
263
+ });
264
+
265
+ test("invokes semantic UI nodes by selector", async () => {
266
+ const { controller, invokedUiActions, setUiNodes } = createRegistryHarness();
267
+ setUiNodes([
268
+ { id: "ui:cancel", role: "button", label: "Cancel", actions: ["press"] },
269
+ { id: "ui:done", role: "button", label: "Done", actions: ["press"], metadata: { scope: "command-bar" } },
270
+ ]);
271
+
272
+ const response = await controller.handle({
273
+ type: "call",
274
+ operation: "ui.invokeMatching",
275
+ input: { role: "button", label: "Done", action: "press" },
276
+ });
277
+
278
+ expect(response.ok).toBe(true);
279
+ expect(invokedUiActions).toContainEqual({ nodeId: "ui:done", action: "press", input: undefined });
280
+
281
+ const directResponse = await controller.handle({
282
+ type: "call",
283
+ operation: "ui.invoke",
284
+ input: { nodeId: "ui:done", action: "press" },
285
+ });
286
+ expect(directResponse.ok).toBe(true);
287
+ if (directResponse.ok) {
288
+ expect(directResponse.data).toMatchObject({
289
+ ok: true,
290
+ result: { nodeId: "ui:done", action: "press" },
291
+ });
292
+ }
293
+ });
294
+
295
+ test("runs sequential batches with halt-on-error and final state", async () => {
296
+ const { controller } = createRegistryHarness();
297
+
298
+ const response = await controller.handle({
299
+ type: "batch",
300
+ include: ["commandBar"],
301
+ requests: [
302
+ { type: "call", operation: "app.openCommandBar", input: { query: "theme" } },
303
+ { type: "call", operation: "missing.operation", input: {} },
304
+ { type: "call", operation: "app.closeCommandBar", input: {} },
305
+ ],
306
+ });
307
+
308
+ expect(response.ok).toBe(true);
309
+ if (response.ok) {
310
+ expect(response.data).toMatchObject({
311
+ ok: false,
312
+ haltedAt: 1,
313
+ });
314
+ expect((response.data as { responses: unknown[] }).responses).toHaveLength(2);
315
+ expect(response.state?.commandBar).toMatchObject({
316
+ open: true,
317
+ });
318
+ }
319
+ });
320
+
321
+ test("patches pane runtime state with replace semantics", async () => {
322
+ const { controller, getState } = createRegistryHarness();
323
+ const paneId = getState().config.layout.instances[0]!.instanceId;
324
+ await controller.handle({
325
+ type: "call",
326
+ operation: "pane.setState",
327
+ input: { paneId, patch: { cursorSymbol: "NVDA", stale: true } },
328
+ });
329
+
330
+ const response = await controller.handle({
331
+ type: "patch",
332
+ resource: `app://pane-state/${encodeURIComponent(paneId)}`,
333
+ patch: [{ op: "remove", path: "/stale" }],
334
+ });
335
+
336
+ expect(response.ok).toBe(true);
337
+ expect(getState().paneState[paneId]).toMatchObject({ cursorSymbol: "NVDA" });
338
+ expect(getState().paneState[paneId]?.stale).toBeUndefined();
339
+ });
340
+
341
+ test("closes floating panes and grids visible panes through layout helpers", async () => {
342
+ const { controller, getState } = createRegistryHarness();
343
+ expect(getState().config.layout.floating.length).toBeGreaterThan(0);
344
+
345
+ const closeResponse = await controller.handle({
346
+ type: "call",
347
+ operation: "layout.closeFloating",
348
+ input: {},
349
+ });
350
+ expect(closeResponse.ok).toBe(true);
351
+ expect(getState().config.layout.floating).toEqual([]);
352
+
353
+ const paneIds = getState().config.layout.instances.slice(0, 4).map((pane) => pane.instanceId);
354
+ const gridResponse = await controller.handle({
355
+ type: "call",
356
+ operation: "layout.setGrid",
357
+ input: { paneIds, columns: 2 },
358
+ });
359
+ expect(gridResponse.ok).toBe(true);
360
+ expect(getState().config.layout.dockRoot).toMatchObject({
361
+ kind: "split",
362
+ axis: "vertical",
363
+ });
364
+ });
365
+ });