gloomberb 0.7.2 → 0.7.3

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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { act, useCallback, useMemo, useRef, useState } from "react";
3
3
  import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
4
4
  import { testRender } from "../../renderers/opentui/test-utils";
@@ -8,7 +8,7 @@ import { colors } from "../../theme/colors";
8
8
  import { createDefaultConfig, findPaneInstance, type PaneInstanceConfig } from "../../types/config";
9
9
  import type { PersistedResourceValue } from "../../types/persistence";
10
10
  import type { PluginPersistence } from "../../types/plugin";
11
- import { Box } from "../../ui";
11
+ import { Box, TextAttributes } from "../../ui";
12
12
  import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
13
13
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
14
14
  import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
@@ -21,6 +21,8 @@ const TRANSCRIPT_SOURCE = "server";
21
21
  const TRANSCRIPT_SCHEMA_VERSION = 2;
22
22
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
23
23
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
24
+ const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
25
+ const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
24
26
 
25
27
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
26
28
 
@@ -150,6 +152,8 @@ function createController(options: {
150
152
  controller.attachPersistence(persistence);
151
153
  controller.refreshSession = async () => {};
152
154
  controller.refreshMessages = async () => {};
155
+ controller.refreshPresence = async () => {};
156
+ controller.refreshChatState = async () => {};
153
157
  return controller;
154
158
  }
155
159
 
@@ -248,11 +252,23 @@ function hexToRgbaInts(hex: string) {
248
252
  ].join(",");
249
253
  }
250
254
 
255
+ beforeEach(() => {
256
+ apiClient.getChatPresence = async () => ({ onlineCount: 0 });
257
+ apiClient.updateChatChannelState = async (channelId, body) => ({
258
+ channelId,
259
+ notificationsEnabled: body.notificationsEnabled ?? false,
260
+ lastReadMessageId: body.readThroughMessageId ?? null,
261
+ unreadCount: 0,
262
+ });
263
+ });
264
+
251
265
  afterEach(async () => {
252
266
  setSharedRegistryForTests(undefined);
253
267
  setSharedMarketDataForTests(undefined);
254
268
  apiClient.connectChannel = originalConnectChannel;
255
269
  apiClient.getChannels = originalGetChannels;
270
+ apiClient.getChatPresence = originalGetChatPresence;
271
+ apiClient.updateChatChannelState = originalUpdateChatChannelState;
256
272
  apiClient.setSessionToken(null);
257
273
 
258
274
  if (testSetup) {
@@ -468,6 +484,136 @@ describe("ChatContent", () => {
468
484
  expect(testSetup.captureCharFrame()).toContain("#options");
469
485
  });
470
486
 
487
+ test("renders unread sidebar channels in bold and clears them when opened", async () => {
488
+ const controller = createController({ sessionToken: "token-123" });
489
+ installServerChannels(controller);
490
+ controller.refreshChannels = async () => {};
491
+ controller.refreshChannelMessages = async () => {};
492
+ const optionsState = (controller as any).ensureChannelState("options");
493
+ optionsState.unreadCount = 2;
494
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
495
+
496
+ function ChannelPane() {
497
+ const [channelId, setChannelId] = useState("equities");
498
+ return (
499
+ <AppContext value={{ state, dispatch: () => {} }}>
500
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
501
+ <ChatContent
502
+ controller={controller}
503
+ width={90}
504
+ height={12}
505
+ focused
506
+ channelId={channelId}
507
+ onChannelChange={setChannelId}
508
+ />
509
+ </PluginRenderProvider>
510
+ </AppContext>
511
+ );
512
+ }
513
+
514
+ await act(async () => {
515
+ testSetup = await testRender(<ChannelPane />, {
516
+ width: 90,
517
+ height: 12,
518
+ });
519
+ });
520
+
521
+ await flushFrame();
522
+ const unreadLine = testSetup.captureSpans().lines.find((line) => lineText(line).includes("options"));
523
+ const unreadSpan = unreadLine?.spans.find((span) => span.text.includes("options"));
524
+ expect((unreadSpan?.attributes ?? 0) & TextAttributes.BOLD).toBe(TextAttributes.BOLD);
525
+
526
+ const lines = testSetup.captureCharFrame().split("\n");
527
+ const row = lines.findIndex((line) => line.includes("options"));
528
+ const col = lines[row]?.indexOf("options") ?? -1;
529
+
530
+ await act(async () => {
531
+ await testSetup!.mockMouse.click(col + 1, row);
532
+ await testSetup!.renderOnce();
533
+ await testSetup!.renderOnce();
534
+ });
535
+ await flushFrame();
536
+
537
+ const readLine = testSetup.captureSpans().lines.find((line) => lineText(line).includes("#options"));
538
+ const readSpan = readLine?.spans.find((span) => span.text.includes("options"));
539
+ expect((readSpan?.attributes ?? 0) & TextAttributes.BOLD).toBe(0);
540
+ });
541
+
542
+ test("toggles sidebar channel notifications without selecting the channel", async () => {
543
+ const controller = createController({ sessionToken: "token-123" });
544
+ installServerChannels(controller);
545
+ controller.refreshChannels = async () => {};
546
+ controller.refreshChannelMessages = async () => {};
547
+ const toggles: Array<{ channelId: string; enabled: boolean }> = [];
548
+ controller.setChannelNotificationsEnabled = (channelId: string, enabled: boolean) => {
549
+ toggles.push({ channelId, enabled });
550
+ };
551
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
552
+
553
+ function ChannelPane() {
554
+ const [channelId, setChannelId] = useState("equities");
555
+ return (
556
+ <AppContext value={{ state, dispatch: () => {} }}>
557
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
558
+ <ChatContent
559
+ controller={controller}
560
+ width={90}
561
+ height={12}
562
+ focused
563
+ channelId={channelId}
564
+ onChannelChange={setChannelId}
565
+ />
566
+ </PluginRenderProvider>
567
+ </AppContext>
568
+ );
569
+ }
570
+
571
+ await act(async () => {
572
+ testSetup = await testRender(<ChannelPane />, {
573
+ width: 90,
574
+ height: 12,
575
+ });
576
+ });
577
+
578
+ await flushFrame();
579
+ const frame = testSetup.captureCharFrame();
580
+ const lines = frame.split("\n");
581
+ const row = lines.findIndex((line) => line.includes("options"));
582
+ const col = lines[row]?.lastIndexOf("·") ?? -1;
583
+ expect(row).toBeGreaterThanOrEqual(0);
584
+ expect(col).toBeGreaterThanOrEqual(0);
585
+
586
+ await act(async () => {
587
+ await testSetup!.mockMouse.click(col, row);
588
+ await testSetup!.renderOnce();
589
+ await testSetup!.renderOnce();
590
+ });
591
+ await flushFrame();
592
+
593
+ expect(toggles).toEqual([{ channelId: "options", enabled: true }]);
594
+ expect(testSetup.captureCharFrame()).toContain("#equities");
595
+ expect(testSetup.captureCharFrame()).not.toContain("#options");
596
+ });
597
+
598
+ test("shows the sidebar online count footer", async () => {
599
+ const controller = createController({ sessionToken: "token-123" });
600
+ installServerChannels(controller);
601
+ controller.refreshChannels = async () => {};
602
+ controller.refreshChannelMessages = async () => {};
603
+ (controller as any).onlineCount = 6;
604
+
605
+ await act(async () => {
606
+ testSetup = await testRender(createHarness(controller, { width: 90, height: 12 }), {
607
+ width: 90,
608
+ height: 12,
609
+ });
610
+ });
611
+
612
+ await flushFrame();
613
+
614
+ expect(testSetup.captureCharFrame()).toContain("● 6 online");
615
+ });
616
+
471
617
  test("uses arrows to move between channel sidebar and chat content", async () => {
472
618
  const controller = createController({ sessionToken: "token-123" });
473
619
  installServerChannels(controller);
@@ -818,6 +964,34 @@ describe("ChatContent", () => {
818
964
  expect(frame).toContain("Reply to @user2...");
819
965
  });
820
966
 
967
+ test("modified k does not move message selection", async () => {
968
+ const controller = createController({
969
+ messages: [makeMessage(1), makeMessage(2)],
970
+ });
971
+
972
+ await act(async () => {
973
+ testSetup = await testRender(createHarness(controller, {
974
+ width: 72,
975
+ height: 12,
976
+ }), {
977
+ width: 72,
978
+ height: 12,
979
+ });
980
+ });
981
+
982
+ await flushFrame();
983
+
984
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
985
+ await emitKeypress({ name: "k", sequence: "k", meta: true });
986
+ await emitKeypress({ name: "return", sequence: "\r" });
987
+ await flushFrame();
988
+
989
+ const frame = testSetup.captureCharFrame();
990
+ expect(frame).toContain("replying to @user2");
991
+ expect(frame).toContain("Reply to @user2...");
992
+ expect(frame).not.toContain("replying to @user1");
993
+ });
994
+
821
995
  test("shows the reply action next to the selected message timestamp", async () => {
822
996
  const controller = createController({
823
997
  messages: [makeMessage(1), makeMessage(2)],
@@ -1448,7 +1622,7 @@ describe("ChatContent", () => {
1448
1622
  expect(frame).not.toContain("message 1");
1449
1623
  });
1450
1624
 
1451
- test("repins the transcript to the latest message when a following chat pane regains focus", async () => {
1625
+ test("preserves a manually scrolled transcript across focus and snapshot refreshes", async () => {
1452
1626
  const controller = createController({
1453
1627
  messages: [
1454
1628
  makeMessage(1),
@@ -1506,13 +1680,90 @@ describe("ChatContent", () => {
1506
1680
 
1507
1681
  await act(async () => {
1508
1682
  setFocused?.(true);
1683
+ (controller as any).emit("everyone");
1684
+ await testSetup!.renderOnce();
1685
+ });
1686
+ await flushFrame();
1687
+
1688
+ const refreshedFrame = testSetup.captureCharFrame();
1689
+ expect(refreshedFrame).toContain("message 2");
1690
+ expect(refreshedFrame).not.toContain("message 8");
1691
+ });
1692
+
1693
+ test("opens a newly selected channel at the latest message", async () => {
1694
+ const controller = createController({
1695
+ messages: Array.from({ length: 8 }, (_, index) => makeMessage(index + 1)),
1696
+ });
1697
+ installServerChannels(controller, TEST_CHAT_CHANNELS.slice(0, 2));
1698
+ controller.refreshChannels = async () => {};
1699
+ controller.refreshChannelMessages = async () => {};
1700
+
1701
+ const equitiesMessages = Array.from({ length: 8 }, (_, index) => ({
1702
+ ...makeMessage(index + 1),
1703
+ id: `o${index + 1}`,
1704
+ channelId: "equities",
1705
+ content: `equities message ${index + 1}`,
1706
+ }));
1707
+ const equitiesState = (controller as any).ensureChannelState("equities");
1708
+ equitiesState.messages = equitiesMessages;
1709
+ equitiesState.lastCursor = "o8";
1710
+
1711
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat-channel-scroll"));
1712
+
1713
+ function ChannelPane() {
1714
+ const [channelId, setChannelId] = useState("everyone");
1715
+ return (
1716
+ <AppContext value={{ state, dispatch: () => {} }}>
1717
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
1718
+ <ChatContent
1719
+ controller={controller}
1720
+ width={90}
1721
+ height={13}
1722
+ focused
1723
+ channelId={channelId}
1724
+ onChannelChange={setChannelId}
1725
+ />
1726
+ </PluginRenderProvider>
1727
+ </AppContext>
1728
+ );
1729
+ }
1730
+
1731
+ await act(async () => {
1732
+ testSetup = await testRender(<ChannelPane />, {
1733
+ width: 90,
1734
+ height: 13,
1735
+ });
1736
+ });
1737
+
1738
+ await flushFrame();
1739
+ expect(testSetup.captureCharFrame()).toContain("message 8");
1740
+
1741
+ await act(async () => {
1742
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1743
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1744
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1745
+ await testSetup!.renderOnce();
1746
+ await testSetup!.renderOnce();
1747
+ });
1748
+ await flushFrame();
1749
+ expect(testSetup.captureCharFrame()).toContain("message 2");
1750
+
1751
+ const lines = testSetup.captureCharFrame().split("\n");
1752
+ const row = lines.findIndex((line) => line.includes("equities"));
1753
+ const col = lines[row]?.indexOf("equities") ?? -1;
1754
+ expect(row).toBeGreaterThanOrEqual(0);
1755
+ expect(col).toBeGreaterThanOrEqual(0);
1756
+
1757
+ await act(async () => {
1758
+ await testSetup!.mockMouse.click(col + 1, row);
1759
+ await testSetup!.renderOnce();
1509
1760
  await testSetup!.renderOnce();
1510
1761
  });
1511
1762
  await flushFrame();
1512
1763
 
1513
- const refocusedFrame = testSetup.captureCharFrame();
1514
- expect(refocusedFrame).toContain("message 8");
1515
- expect(refocusedFrame).not.toContain("message 2");
1764
+ const channelFrame = testSetup.captureCharFrame();
1765
+ expect(channelFrame).toContain("equities message 8");
1766
+ expect(channelFrame).not.toContain("equities message 1");
1516
1767
  });
1517
1768
 
1518
1769
  test("keeps the bottom reachable after narrowing the chat pane", async () => {