gloomberb 0.6.1 → 0.7.1

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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -461,8 +461,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
461
461
  const selectedResult = activeTab?.results.find((result) => result.symbol === cursorSymbol)
462
462
  ?? activeTab?.results[0]
463
463
  ?? null;
464
- const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
465
- const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
466
464
  const isRunningActiveTab = runState?.tabId === activeTab?.id;
467
465
  const forceRunArmed = !!activeTab && forceConfirmTabId === activeTab.id;
468
466
 
@@ -753,7 +751,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
753
751
  return;
754
752
  }
755
753
 
756
- const key = event.name;
757
754
  if (event.name === "t") {
758
755
  addTab();
759
756
  return;
@@ -762,11 +759,11 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
762
759
  removeTab(activeTab.id);
763
760
  return;
764
761
  }
765
- if (event.name === "[" || event.name === "left") {
762
+ if (event.name === "[") {
766
763
  cycleTabs(-1);
767
764
  return;
768
765
  }
769
- if (event.name === "]" || event.name === "right") {
766
+ if (event.name === "]") {
770
767
  cycleTabs(1);
771
768
  return;
772
769
  }
@@ -920,6 +917,7 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
920
917
  variant="pill"
921
918
  closeMode="active"
922
919
  onAdd={editorState ? undefined : addTab}
920
+ focused={focused && !editorState}
923
921
  />
924
922
  </Box>
925
923
 
@@ -1,7 +1,6 @@
1
1
  import { Box, Text } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { TextAttributes } from "../../../ui";
4
- import { useShortcut } from "../../../react/input";
5
4
  import { DataTableView, Tabs, type DataTableColumn } from "../../../components";
6
5
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
7
6
  import type { ColumnConfig } from "../../../types/config";
@@ -278,7 +277,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
278
277
  () => portfolios.find((portfolio) => portfolio.id === activePortfolioId) ?? null,
279
278
  [activePortfolioId, portfolios],
280
279
  );
281
- const activePortfolioIndex = portfolios.findIndex((portfolio) => portfolio.id === activePortfolioId);
282
280
  const portfolioTabs = useMemo(
283
281
  () => portfolios.map((portfolio) => ({ label: portfolio.name, value: portfolio.id })),
284
282
  [portfolios],
@@ -555,22 +553,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
555
553
  setSectorSort((current) => nextSectorSortPreference(current, columnId));
556
554
  }, []);
557
555
 
558
- useShortcut((event) => {
559
- if (!focused) return;
560
-
561
- const key = event.name;
562
- if ((key === "h" || key === "left") && activePortfolioIndex > 0) {
563
- const previousPortfolio = portfolios[activePortfolioIndex - 1];
564
- if (previousPortfolio) handlePortfolioSelect(previousPortfolio.id);
565
- return;
566
- }
567
- if ((key === "l" || key === "right") && activePortfolioIndex >= 0 && activePortfolioIndex < portfolios.length - 1) {
568
- const nextPortfolio = portfolios[activePortfolioIndex + 1];
569
- if (nextPortfolio) handlePortfolioSelect(nextPortfolio.id);
570
- return;
571
- }
572
- });
573
-
574
556
  useEffect(() => {
575
557
  if (activePortfolioId !== currentPortfolioId) {
576
558
  setCurrentPortfolioId(activePortfolioId);
@@ -592,6 +574,7 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
592
574
  activeValue={activePortfolioId}
593
575
  onSelect={handlePortfolioSelect}
594
576
  compact
577
+ focused={focused}
595
578
  />
596
579
  </Box>
597
580
  </Box>
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act } from "react";
2
+ import { act, useReducer } from "react";
3
3
  import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
4
4
  import { testRender } from "../../renderers/opentui/test-utils";
5
- import { AppContext, PaneInstanceProvider, createInitialState } from "../../state/app-context";
5
+ import { AppContext, PaneInstanceProvider, appReducer, createInitialState } from "../../state/app-context";
6
6
  import { createStatefulTestPluginRuntime } from "../../test-support/plugin-runtime";
7
7
  import { createDefaultConfig } from "../../types/config";
8
8
  import { Box } from "../../ui";
@@ -258,4 +258,91 @@ describe("AskAiTab", () => {
258
258
 
259
259
  expect(opened).toEqual(["AAPL"]);
260
260
  });
261
+
262
+ test("does not persist previous ticker messages while hydrating a newly selected ticker", async () => {
263
+ setProviders([
264
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
265
+ ]);
266
+
267
+ const runtime = createStatefulTestPluginRuntime();
268
+ runtime.setResumeState("ai", "conversation:claude:AAPL", {
269
+ updatedAt: Date.now(),
270
+ messages: [
271
+ { role: "user", content: "AAPL question" },
272
+ { role: "assistant", content: "AAPL answer", loading: false },
273
+ ],
274
+ }, 1);
275
+ runtime.setResumeState("ai", "conversation:claude:MSFT", {
276
+ updatedAt: Date.now(),
277
+ messages: [
278
+ { role: "user", content: "MSFT question" },
279
+ { role: "assistant", content: "MSFT answer", loading: false },
280
+ ],
281
+ }, 1);
282
+
283
+ let selectSymbol: ((symbol: string) => void) | null = null;
284
+
285
+ const config = createDefaultConfig("/tmp/gloomberb-ai-navigation");
286
+ const initialState = createInitialState(config);
287
+ initialState.focusedPaneId = PANE_ID;
288
+ initialState.paneState["portfolio-list:main"] = {
289
+ ...initialState.paneState["portfolio-list:main"],
290
+ cursorSymbol: "AAPL",
291
+ };
292
+ initialState.tickers = new Map([
293
+ ["AAPL", makeTicker("AAPL", "Apple Inc.")],
294
+ ["MSFT", makeTicker("MSFT", "Microsoft Corporation")],
295
+ ]);
296
+
297
+ function NavigableAskAiHarness() {
298
+ const [state, dispatch] = useReducer(appReducer, initialState);
299
+ selectSymbol = (symbol) => {
300
+ dispatch({
301
+ type: "UPDATE_PANE_STATE",
302
+ paneId: "portfolio-list:main",
303
+ patch: { cursorSymbol: symbol },
304
+ });
305
+ };
306
+
307
+ return (
308
+ <AppContext value={{ state, dispatch }}>
309
+ <PaneInstanceProvider paneId={PANE_ID}>
310
+ <PluginRenderProvider pluginId="ai" runtime={runtime}>
311
+ <PaneFooterProvider>
312
+ {(footer) => (
313
+ <Box flexDirection="column" width={60} height={12}>
314
+ <AskAiTab width={60} height={11} focused onCapture={() => {}} />
315
+ <PaneFooterBar footer={footer} focused width={60} />
316
+ </Box>
317
+ )}
318
+ </PaneFooterProvider>
319
+ </PluginRenderProvider>
320
+ </PaneInstanceProvider>
321
+ </AppContext>
322
+ );
323
+ }
324
+
325
+ await act(async () => {
326
+ testSetup = await testRender(<NavigableAskAiHarness />, {
327
+ width: 60,
328
+ height: 12,
329
+ });
330
+ });
331
+ await flushFrame();
332
+
333
+ expect(testSetup.captureCharFrame()).toContain("AAPL answer");
334
+
335
+ await act(() => {
336
+ selectSymbol?.("MSFT");
337
+ });
338
+ await flushFrame();
339
+
340
+ const frame = testSetup.captureCharFrame();
341
+ expect(frame).toContain("MSFT answer");
342
+ expect(frame).not.toContain("AAPL answer");
343
+ expect((runtime.getResumeState("ai", "conversation:claude:MSFT") as any)?.messages).toEqual([
344
+ { role: "user", content: "MSFT question" },
345
+ { role: "assistant", content: "MSFT answer", loading: false },
346
+ ]);
347
+ });
261
348
  });
@@ -65,7 +65,7 @@ function Harness({
65
65
  const runtime = createTestPluginRuntime({
66
66
  getBrokerAdapter: (brokerType) => brokerType === "ibkr" ? ibkrBroker : null,
67
67
  openCommandBar: (query) => calls.push(`command:${query ?? ""}`),
68
- showWidget: (paneId) => calls.push(`widget:${paneId}`),
68
+ showPane: (paneId) => calls.push(`pane:${paneId}`),
69
69
  connectBrokerInstance: async (instanceId) => { calls.push(`connect:${instanceId}`); },
70
70
  syncBrokerInstance: async (instanceId) => { calls.push(`sync:${instanceId}`); },
71
71
  updateBrokerInstance: async (instanceId) => { calls.push(`update:${instanceId}`); },
@@ -163,6 +163,6 @@ describe("BrokersPane", () => {
163
163
  await pressKey("s");
164
164
  await pressKey("o");
165
165
 
166
- expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "widget:ibkr-trading"]);
166
+ expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "pane:ibkr-trading"]);
167
167
  });
168
168
  });
@@ -2,6 +2,7 @@ import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  Button,
5
+ ConfirmDialog,
5
6
  DataTableStackView,
6
7
  NumberField,
7
8
  SegmentedControl,
@@ -29,8 +30,7 @@ import type { BrokerAdapter, BrokerConfigField, BrokerProfileAction } from "../.
29
30
  import type { BrokerInstanceConfig } from "../../../types/config";
30
31
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
31
32
  import type { BrokerAccount } from "../../../types/trading";
32
- import { DialogFrame } from "../../../components/ui/frame";
33
- import { useDialog, useDialogKeyboard, type PromptContext } from "../../../ui/dialog";
33
+ import { useDialog, type PromptContext } from "../../../ui/dialog";
34
34
  import { formatCurrency } from "../../../utils/format";
35
35
  import { usePluginAppActions, usePluginBrokerActions } from "../../plugin-runtime";
36
36
  import {
@@ -76,34 +76,6 @@ function isBrokerErrorMessage(message: string | null | undefined): boolean {
76
76
  return normalized.includes("failed") || normalized.includes("required");
77
77
  }
78
78
 
79
- function ConfirmRemoveBrokerDialog({
80
- resolve,
81
- row,
82
- }: PromptContext<boolean> & { row: BrokerProfileRow }) {
83
- const confirm = useCallback(() => resolve(true), [resolve]);
84
- const cancel = useCallback(() => resolve(false), [resolve]);
85
-
86
- useDialogKeyboard((event) => {
87
- event.stopPropagation();
88
- if (event.name === "enter" || event.name === "return") confirm();
89
- if (event.name === "escape") cancel();
90
- });
91
-
92
- return (
93
- <DialogFrame title="Disconnect broker?" footer="Enter disconnect · Esc cancel">
94
- <Box flexDirection="column" width={58}>
95
- <Text fg={colors.text}>{`Remove "${row.label}" and imported broker data?`}</Text>
96
- <Text fg={colors.textDim}>Broker-managed portfolios, positions, and contracts will be removed.</Text>
97
- <Box height={1} />
98
- <Box flexDirection="row" gap={1}>
99
- <Button label="Disconnect" variant="danger" onPress={confirm} />
100
- <Button label="Back" variant="secondary" onPress={cancel} />
101
- </Box>
102
- </Box>
103
- </DialogFrame>
104
- );
105
- }
106
-
107
79
  function brokerFieldLabel(field: BrokerConfigField, focused: boolean): string {
108
80
  return focused ? `> ${field.label}` : ` ${field.label}`;
109
81
  }
@@ -202,7 +174,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
202
174
  const config = useAppSelector((state) => state.config);
203
175
  const brokerAccounts = useAppSelector((state) => state.brokerAccounts);
204
176
  const dialog = useDialog();
205
- const { openCommandBar, showWidget } = usePluginAppActions();
177
+ const { openCommandBar, showPane } = usePluginAppActions();
206
178
  const {
207
179
  getBrokerAdapter,
208
180
  connectBrokerInstance,
@@ -363,15 +335,26 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
363
335
  setMessage(action.disabledReason ?? `${action.label} is unavailable for this profile.`);
364
336
  return;
365
337
  }
366
- if (action.widgetId) showWidget(action.widgetId);
367
- }, [primaryProfileAction, showWidget]);
338
+ if (action.paneId) showPane(action.paneId);
339
+ }, [primaryProfileAction, showPane]);
368
340
 
369
341
  const removeSelected = useCallback(async () => {
370
342
  if (!selectedRow) return;
371
343
  const confirmed = await dialog.prompt<boolean>({
372
344
  closeOnClickOutside: true,
373
345
  content: (ctx: PromptContext<boolean>) => (
374
- <ConfirmRemoveBrokerDialog {...ctx} row={selectedRow} />
346
+ <ConfirmDialog
347
+ {...ctx}
348
+ title="Disconnect broker?"
349
+ body={[
350
+ `Remove "${selectedRow.label}" and imported broker data?`,
351
+ "Broker-managed portfolios, positions, and contracts will be removed.",
352
+ ]}
353
+ confirmLabel="Disconnect"
354
+ cancelLabel="Back"
355
+ width={58}
356
+ footer="Enter disconnect · Esc cancel"
357
+ />
375
358
  ),
376
359
  }).catch(() => false);
377
360
  if (confirmed !== true) return;
@@ -392,7 +375,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
392
375
  const hasSelectedRow = selectedRow !== null;
393
376
  const selectedHasAdapter = !!selectedRow?.adapter;
394
377
  const canUseSelectedBroker = selectedHasAdapter && !busy;
395
- const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.widgetId);
378
+ const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.paneId);
396
379
  const canRemoveSelected = hasSelectedRow && !busy;
397
380
 
398
381
  const footerActionsRef = useRef({
@@ -752,7 +735,7 @@ export const brokerManagerPlugin: GloomPlugin = {
752
735
  keywords: ["broker", "brokers", "connection", "accounts", "sync"],
753
736
  category: "navigation",
754
737
  execute: () => {
755
- ctx.showWidget("brokers");
738
+ ctx.showPane("brokers");
756
739
  },
757
740
  });
758
741
  },
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { AppNotificationRequest, PluginPersistence } from "../../types/plugin";
3
3
  import type { PersistedResourceValue } from "../../types/persistence";
4
- import { apiClient, type ChatMessage } from "../../utils/api-client";
4
+ import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
5
5
  import { ChatController } from "./chat-controller";
6
6
 
7
7
  const TRANSCRIPT_KIND = "channel-transcript";
@@ -11,6 +11,14 @@ const TRANSCRIPT_SCHEMA_VERSION = 2;
11
11
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
12
12
  const originalGetSession = apiClient.getSession.bind(apiClient);
13
13
  const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
+ const originalGetChannels = apiClient.getChannels.bind(apiClient);
15
+
16
+ const SERVER_CHAT_CHANNELS: ChatChannel[] = [
17
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
18
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
19
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
20
+ { id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" },
21
+ ];
14
22
 
15
23
  async function flushMicrotasks() {
16
24
  await Promise.resolve();
@@ -83,12 +91,22 @@ class MemoryPersistence implements PluginPersistence {
83
91
  }
84
92
  }
85
93
 
94
+ class TrackingPersistence extends MemoryPersistence {
95
+ stateWrites = 0;
96
+
97
+ setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
98
+ this.stateWrites += 1;
99
+ super.setState(key, value, options);
100
+ }
101
+ }
102
+
86
103
  afterEach(() => {
87
104
  apiClient.dispose();
88
105
  apiClient.setSessionToken(null);
89
106
  apiClient.connectChannel = originalConnectChannel;
90
107
  apiClient.getSession = originalGetSession;
91
108
  apiClient.getMessages = originalGetMessages;
109
+ apiClient.getChannels = originalGetChannels;
92
110
  });
93
111
 
94
112
  describe("ChatController", () => {
@@ -132,6 +150,27 @@ describe("ChatController", () => {
132
150
  expect(snapshot.messages.map((entry) => entry.id)).toEqual(["m1"]);
133
151
  });
134
152
 
153
+ test("starts without client-seeded channels and hydrates the server channel list", async () => {
154
+ const controller = new ChatController();
155
+ apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
156
+
157
+ expect(controller.getSnapshot().channels).toEqual([]);
158
+
159
+ await controller.refreshChannels();
160
+
161
+ expect(controller.getSnapshot().channels).toEqual(SERVER_CHAT_CHANNELS);
162
+ });
163
+
164
+ test("rejects unknown shortcut channels after the server list loads", async () => {
165
+ const controller = new ChatController();
166
+ apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
167
+
168
+ await expect(controller.resolveRequiredChannelId("help")).resolves.toBe("help");
169
+ await expect(controller.resolveRequiredChannelId("made-up")).rejects.toThrow(
170
+ 'Unknown chat channel "#made-up".',
171
+ );
172
+ });
173
+
135
174
  test("hydrates a cached verified user into the api client for offline use", async () => {
136
175
  const persistence = new MemoryPersistence();
137
176
  const controller = new ChatController();
@@ -209,6 +248,34 @@ describe("ChatController", () => {
209
248
  })).toBeNull();
210
249
  });
211
250
 
251
+ test("defers draft persistence and subscriber sync until the user pauses or leaves", () => {
252
+ const persistence = new TrackingPersistence();
253
+ const controller = new ChatController();
254
+ const draftSnapshots: string[] = [];
255
+
256
+ controller.attachPersistence(persistence);
257
+ const unsubscribe = controller.subscribe((snapshot) => {
258
+ draftSnapshots.push(snapshot.draft);
259
+ });
260
+ draftSnapshots.length = 0;
261
+ persistence.stateWrites = 0;
262
+
263
+ controller.setDraft("h");
264
+ controller.setDraft("he");
265
+
266
+ expect(controller.getSnapshot().draft).toBe("he");
267
+ expect(draftSnapshots).toEqual([]);
268
+ expect(persistence.stateWrites).toBe(0);
269
+ expect(persistence.getState("channel:everyone", { schemaVersion: 1 })).toBeNull();
270
+
271
+ unsubscribe();
272
+ controller.dispose();
273
+
274
+ expect(persistence.getState<{ draft: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
275
+ draft: "he",
276
+ });
277
+ });
278
+
212
279
  test("pauses verification polling while the app is backgrounded", () => {
213
280
  const controller = new ChatController();
214
281
 
@@ -254,6 +321,74 @@ describe("ChatController", () => {
254
321
  expect((controller as any).wsConnection).toBeNull();
255
322
  });
256
323
 
324
+ test("runs a quiet safety refresh while the live connection is active", async () => {
325
+ const persistence = new MemoryPersistence();
326
+ const controller = new ChatController();
327
+ const originalSetInterval = globalThis.setInterval;
328
+ const originalClearInterval = globalThis.clearInterval;
329
+ const intervalCallbacks: Array<() => void> = [];
330
+ const intervalHandle = { unref: () => {} };
331
+ let cleared = false;
332
+ let getMessagesCalls = 0;
333
+ const loadingSnapshots: boolean[] = [];
334
+
335
+ persistence.setState("session", {
336
+ sessionToken: "token-123",
337
+ user: { id: "u1", username: "vince", emailVerified: true },
338
+ }, { schemaVersion: 1 });
339
+
340
+ (globalThis as any).setInterval = (callback: () => void, timeout: number) => {
341
+ expect(timeout).toBe(30_000);
342
+ intervalCallbacks.push(callback);
343
+ return intervalHandle;
344
+ };
345
+ (globalThis as any).clearInterval = (handle: unknown) => {
346
+ if (handle === intervalHandle) {
347
+ cleared = true;
348
+ }
349
+ };
350
+
351
+ try {
352
+ apiClient.getMessages = async () => {
353
+ getMessagesCalls += 1;
354
+ return [];
355
+ };
356
+ apiClient.connectChannel = () => ({
357
+ send: async () => {
358
+ throw new Error("not implemented");
359
+ },
360
+ close: () => {},
361
+ });
362
+
363
+ controller.attachPersistence(persistence);
364
+ controller.ensureConnection();
365
+ await flushMicrotasks();
366
+
367
+ expect(intervalCallbacks).toHaveLength(1);
368
+ expect(getMessagesCalls).toBe(1);
369
+ expect(controller.getSnapshot().loading).toBe(false);
370
+
371
+ controller.subscribe((snapshot) => {
372
+ loadingSnapshots.push(snapshot.loading);
373
+ });
374
+ loadingSnapshots.length = 0;
375
+
376
+ intervalCallbacks[0]!();
377
+ await flushMicrotasks();
378
+
379
+ expect(getMessagesCalls).toBe(2);
380
+ expect(loadingSnapshots).toEqual([false]);
381
+
382
+ controller.clearSession();
383
+ expect((controller as any).safetyRefreshTimer).toBeNull();
384
+ expect(cleared).toBe(true);
385
+ } finally {
386
+ controller.dispose();
387
+ (globalThis as any).setInterval = originalSetInterval;
388
+ (globalThis as any).clearInterval = originalClearInterval;
389
+ }
390
+ });
391
+
257
392
  test("keeps the cached session when session refresh fails transiently", async () => {
258
393
  const persistence = new MemoryPersistence();
259
394
  const controller = new ChatController();
@@ -307,6 +442,58 @@ describe("ChatController", () => {
307
442
  });
308
443
  });
309
444
 
445
+ test("keeps per-channel drafts and transcripts isolated", () => {
446
+ const persistence = new MemoryPersistence();
447
+ const controller = new ChatController();
448
+ const everyoneMessage: ChatMessage = {
449
+ id: "m1",
450
+ channelId: "everyone",
451
+ content: "general",
452
+ replyToId: null,
453
+ createdAt: "2026-03-28T00:00:00.000Z",
454
+ user: { id: "u1", username: "vince", displayName: "Vince" },
455
+ };
456
+ const optionsMessage: ChatMessage = {
457
+ id: "m2",
458
+ channelId: "options",
459
+ content: "options note",
460
+ replyToId: null,
461
+ createdAt: "2026-03-28T00:01:00.000Z",
462
+ user: { id: "u2", username: "bob", displayName: "Bob" },
463
+ };
464
+
465
+ persistence.setState("channel:everyone", {
466
+ draft: "general draft",
467
+ replyToId: null,
468
+ lastCursor: "m1",
469
+ lastViewedMessageId: "m1",
470
+ }, { schemaVersion: 1 });
471
+ persistence.setState("channel:options", {
472
+ draft: "options draft",
473
+ replyToId: null,
474
+ lastCursor: "m2",
475
+ lastViewedMessageId: "m2",
476
+ }, { schemaVersion: 1 });
477
+ persistence.setResource(TRANSCRIPT_KIND, "everyone", { messages: [everyoneMessage] }, {
478
+ sourceKey: TRANSCRIPT_SOURCE,
479
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
480
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
481
+ });
482
+ persistence.setResource(TRANSCRIPT_KIND, "options", { messages: [optionsMessage] }, {
483
+ sourceKey: TRANSCRIPT_SOURCE,
484
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
485
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
486
+ });
487
+
488
+ controller.attachPersistence(persistence);
489
+ controller.setChannelDraft("options", "updated options");
490
+
491
+ expect(controller.getSnapshot().messages.map((entry) => entry.content)).toEqual(["general"]);
492
+ expect(controller.getSnapshot().draft).toBe("general draft");
493
+ expect(controller.getSnapshot("options").messages.map((entry) => entry.content)).toEqual(["options note"]);
494
+ expect(controller.getSnapshot("options").draft).toBe("updated options");
495
+ });
496
+
310
497
  test("stores the latest message id as the incremental cursor", async () => {
311
498
  const persistence = new MemoryPersistence();
312
499
  const controller = new ChatController();