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
@@ -1,28 +1,43 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act, useState } from "react";
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";
5
- import { AppContext, createInitialState } from "../../state/app-context";
6
- import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
5
+ import { AppContext, appReducer, createInitialState, PaneInstanceProvider } from "../../state/app-context";
6
+ import { createConfigBackedTestPluginRuntime, createTestPluginRuntime } from "../../test-support/plugin-runtime";
7
7
  import { colors } from "../../theme/colors";
8
- import { createDefaultConfig } from "../../types/config";
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
11
  import { Box } from "../../ui";
12
- import { apiClient, type ChatMessage } from "../../utils/api-client";
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";
15
- import { ChatContent, ChatStatusWidget, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
16
- import { ChatController } from "./chat-controller";
15
+ import { ChatContent, ChatStatusWidget, getScrollTopForElementIntoView, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
16
+ import { chatController, ChatController } from "./chat-controller";
17
17
 
18
18
  const TRANSCRIPT_KIND = "channel-transcript";
19
19
  const TRANSCRIPT_KEY = "everyone";
20
20
  const TRANSCRIPT_SOURCE = "server";
21
21
  const TRANSCRIPT_SCHEMA_VERSION = 2;
22
22
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
23
+ const originalGetChannels = apiClient.getChannels.bind(apiClient);
23
24
 
24
25
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
25
26
 
27
+ const TEST_CHAT_CHANNELS: ChatChannel[] = [
28
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
29
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
30
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
31
+ { id: "macro", name: "macro", created_at: "2026-05-09T00:00:00.000Z" },
32
+ { id: "crypto", name: "crypto", created_at: "2026-05-09T00:00:00.000Z" },
33
+ { id: "energy", name: "energy", created_at: "2026-05-09T00:00:00.000Z" },
34
+ { id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" },
35
+ ];
36
+
37
+ function installServerChannels(controller: ChatController, channels = TEST_CHAT_CHANNELS): void {
38
+ (controller as any).channels = channels;
39
+ }
40
+
26
41
  class MemoryPersistence implements PluginPersistence {
27
42
  private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
28
43
  private readonly resources = new Map<string, PersistedResourceValue<unknown>>();
@@ -237,6 +252,7 @@ afterEach(async () => {
237
252
  setSharedRegistryForTests(undefined);
238
253
  setSharedMarketDataForTests(undefined);
239
254
  apiClient.connectChannel = originalConnectChannel;
255
+ apiClient.getChannels = originalGetChannels;
240
256
  apiClient.setSessionToken(null);
241
257
 
242
258
  if (testSetup) {
@@ -356,6 +372,341 @@ describe("ChatContent", () => {
356
372
  expect(frame).not.toContain("click a message");
357
373
  });
358
374
 
375
+ test("shows the channel sidebar on wide panes and hides it on narrow panes", async () => {
376
+ const controller = createController({ sessionToken: "token-123" });
377
+ installServerChannels(controller);
378
+ controller.refreshChannels = async () => {};
379
+ controller.refreshChannelMessages = async () => {};
380
+
381
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
382
+ const renderChannelPane = (width: number) => (
383
+ <AppContext value={{ state, dispatch: () => {} }}>
384
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
385
+ <ChatContent
386
+ controller={controller}
387
+ width={width}
388
+ height={12}
389
+ focused
390
+ channelId="options"
391
+ onChannelChange={() => {}}
392
+ />
393
+ </PluginRenderProvider>
394
+ </AppContext>
395
+ );
396
+
397
+ await act(async () => {
398
+ testSetup = await testRender(renderChannelPane(90), {
399
+ width: 90,
400
+ height: 12,
401
+ });
402
+ });
403
+
404
+ await flushFrame();
405
+ expect(testSetup.captureCharFrame()).toContain("options");
406
+
407
+ await act(async () => {
408
+ testSetup?.renderer.destroy();
409
+ testSetup = await testRender(renderChannelPane(60), {
410
+ width: 60,
411
+ height: 12,
412
+ });
413
+ });
414
+
415
+ await flushFrame();
416
+ expect(testSetup.captureCharFrame()).not.toContain("options");
417
+ });
418
+
419
+ test("selects a sidebar channel from a single text click", async () => {
420
+ const controller = createController({ sessionToken: "token-123" });
421
+ installServerChannels(controller);
422
+ controller.refreshChannels = async () => {};
423
+ controller.refreshChannelMessages = async () => {};
424
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
425
+
426
+ function ChannelPane() {
427
+ const [channelId, setChannelId] = useState("equities");
428
+ return (
429
+ <AppContext value={{ state, dispatch: () => {} }}>
430
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
431
+ <ChatContent
432
+ controller={controller}
433
+ width={90}
434
+ height={12}
435
+ focused
436
+ channelId={channelId}
437
+ onChannelChange={setChannelId}
438
+ />
439
+ </PluginRenderProvider>
440
+ </AppContext>
441
+ );
442
+ }
443
+
444
+ await act(async () => {
445
+ testSetup = await testRender(<ChannelPane />, {
446
+ width: 90,
447
+ height: 12,
448
+ });
449
+ });
450
+
451
+ await flushFrame();
452
+ expect(testSetup.captureCharFrame()).toContain("#equities");
453
+
454
+ const lines = testSetup.captureCharFrame().split("\n");
455
+ const row = lines.findIndex((line) => line.includes("options"));
456
+ const col = lines[row]?.indexOf("options") ?? -1;
457
+
458
+ expect(row).toBeGreaterThanOrEqual(0);
459
+ expect(col).toBeGreaterThanOrEqual(0);
460
+
461
+ await act(async () => {
462
+ await testSetup!.mockMouse.click(col + 1, row);
463
+ await testSetup!.renderOnce();
464
+ await testSetup!.renderOnce();
465
+ });
466
+ await flushFrame();
467
+
468
+ expect(testSetup.captureCharFrame()).toContain("#options");
469
+ });
470
+
471
+ test("uses arrows to move between channel sidebar and chat content", async () => {
472
+ const controller = createController({ sessionToken: "token-123" });
473
+ installServerChannels(controller);
474
+ controller.refreshChannels = async () => {};
475
+ controller.refreshChannelMessages = async () => {};
476
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
477
+
478
+ function ChannelPane() {
479
+ const [channelId, setChannelId] = useState("equities");
480
+ return (
481
+ <AppContext value={{ state, dispatch: () => {} }}>
482
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
483
+ <ChatContent
484
+ controller={controller}
485
+ width={90}
486
+ height={12}
487
+ focused
488
+ channelId={channelId}
489
+ onChannelChange={setChannelId}
490
+ />
491
+ </PluginRenderProvider>
492
+ </AppContext>
493
+ );
494
+ }
495
+
496
+ await act(async () => {
497
+ testSetup = await testRender(<ChannelPane />, {
498
+ width: 90,
499
+ height: 12,
500
+ });
501
+ });
502
+
503
+ await flushFrame();
504
+ expect(testSetup.captureCharFrame()).toContain("#equities");
505
+ const getActiveChannelBackgrounds = () => {
506
+ const activeLine = testSetup!.captureSpans().lines.find((line) => lineText(line).includes("#equities"));
507
+ expect(activeLine).toBeDefined();
508
+ return activeLine!.spans.map((span) => span.bg.toInts().join(","));
509
+ };
510
+ const getContentBackgrounds = () => {
511
+ const contentLine = testSetup!.captureSpans().lines.find((line) => lineText(line).includes("No messages yet"));
512
+ expect(contentLine).toBeDefined();
513
+ return contentLine!.spans.map((span) => span.bg.toInts().join(","));
514
+ };
515
+ const contentFocusedBackgrounds = getActiveChannelBackgrounds();
516
+ const rightFocusedBackgrounds = getContentBackgrounds();
517
+
518
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
519
+ expect(testSetup.captureCharFrame()).toContain("#equities");
520
+
521
+ await emitKeypress({ name: "left", sequence: "\u001b[D" });
522
+ await flushFrame();
523
+ expect(getActiveChannelBackgrounds()).not.toEqual(contentFocusedBackgrounds);
524
+ expect(getContentBackgrounds()).not.toEqual(rightFocusedBackgrounds);
525
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
526
+ await flushFrame();
527
+ expect(testSetup.captureCharFrame()).toContain("#options");
528
+
529
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
530
+ await flushFrame();
531
+ expect(testSetup.captureCharFrame()).toContain("#equities");
532
+
533
+ await emitKeypress({ name: "right", sequence: "\u001b[C" });
534
+ await flushFrame();
535
+ expect(getActiveChannelBackgrounds()).toEqual(contentFocusedBackgrounds);
536
+ expect(getContentBackgrounds()).toEqual(rightFocusedBackgrounds);
537
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
538
+ await flushFrame();
539
+ expect(testSetup.captureCharFrame()).toContain("#equities");
540
+ });
541
+
542
+ test("keeps the channel sidebar visible while a channel loads", async () => {
543
+ const controller = createController({ sessionToken: "token-123" });
544
+ installServerChannels(controller);
545
+ const getSnapshot = controller.getSnapshot.bind(controller);
546
+ controller.getSnapshot = ((channelId?: string) => ({
547
+ ...getSnapshot(channelId),
548
+ loading: true,
549
+ messages: [],
550
+ })) as typeof controller.getSnapshot;
551
+ controller.refreshChannels = async () => {};
552
+ controller.refreshChannelMessages = async () => {};
553
+
554
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
555
+
556
+ await act(async () => {
557
+ testSetup = await testRender(
558
+ <AppContext value={{ state, dispatch: () => {} }}>
559
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
560
+ <ChatContent
561
+ controller={controller}
562
+ width={90}
563
+ height={12}
564
+ focused
565
+ channelId="options"
566
+ onChannelChange={() => {}}
567
+ />
568
+ </PluginRenderProvider>
569
+ </AppContext>,
570
+ {
571
+ width: 90,
572
+ height: 12,
573
+ },
574
+ );
575
+ });
576
+
577
+ await flushFrame();
578
+
579
+ const frame = testSetup.captureCharFrame();
580
+ expect(frame).toContain("#options");
581
+ expect(frame).toContain("Loading...");
582
+ });
583
+
584
+ test("persists a pane channel selection without the last-visited write reverting it", async () => {
585
+ const registeredPanes: Array<{ id: string; component: (props: any) => JSX.Element }> = [];
586
+ gloomberbCloudPlugin.setup({
587
+ persistence: new MemoryPersistence(),
588
+ resume: {
589
+ getState: () => null,
590
+ setState: () => {},
591
+ deleteState: () => {},
592
+ },
593
+ registerPane: (pane: { id: string; component: (props: any) => JSX.Element }) => {
594
+ registeredPanes.push(pane);
595
+ },
596
+ registerPaneTemplate: () => {},
597
+ registerDetailTab: () => {},
598
+ registerShortcut: () => {},
599
+ registerCommand: () => {},
600
+ showPane: () => {},
601
+ hidePane: () => {},
602
+ notify: () => {},
603
+ } as any);
604
+ const ChatPaneComponent = registeredPanes.find((pane) => pane.id === "chat")?.component;
605
+ expect(ChatPaneComponent).toBeDefined();
606
+ const ResolvedChatPaneComponent = ChatPaneComponent!;
607
+
608
+ const originalRefreshChannels = chatController.refreshChannels;
609
+ const originalRefreshSession = chatController.refreshSession;
610
+ const originalRefreshChannelMessages = chatController.refreshChannelMessages;
611
+ installServerChannels(chatController);
612
+ chatController.refreshChannels = async () => {};
613
+ chatController.refreshSession = async () => {};
614
+ chatController.refreshChannelMessages = async () => {};
615
+
616
+ const paneInstanceId = "chat:test";
617
+ const chatInstance: PaneInstanceConfig = {
618
+ instanceId: paneInstanceId,
619
+ paneId: "chat",
620
+ settings: { channelId: "equities" },
621
+ };
622
+ const layout = {
623
+ dockRoot: { kind: "pane" as const, instanceId: paneInstanceId },
624
+ floating: [],
625
+ detached: [],
626
+ instances: [chatInstance],
627
+ };
628
+ const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
629
+ initialState.focusedPaneId = paneInstanceId;
630
+ initialState.config = {
631
+ ...initialState.config,
632
+ layout,
633
+ layouts: [{ name: "Test", layout }],
634
+ pluginConfig: {
635
+ "gloomberb-cloud": {
636
+ lastChatChannelId: "equities",
637
+ },
638
+ },
639
+ };
640
+ let latestState = initialState;
641
+
642
+ function ChatPaneHarness() {
643
+ const [state, setState] = useState(initialState);
644
+ const stateRef = useRef(state);
645
+ stateRef.current = state;
646
+ latestState = state;
647
+ const dispatch = useCallback((action: any) => {
648
+ setState((current) => {
649
+ const next = appReducer(current, action);
650
+ latestState = next;
651
+ return next;
652
+ });
653
+ }, []);
654
+ const runtime = useMemo(() => createConfigBackedTestPluginRuntime({
655
+ getConfig: () => stateRef.current.config,
656
+ setConfig: (config) => dispatch({ type: "SET_CONFIG", config }),
657
+ }), [dispatch]);
658
+
659
+ return (
660
+ <AppContext value={{ state, dispatch }}>
661
+ <PaneInstanceProvider paneId={paneInstanceId}>
662
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={runtime}>
663
+ <ResolvedChatPaneComponent
664
+ width={90}
665
+ height={12}
666
+ focused
667
+ close={() => {}}
668
+ />
669
+ </PluginRenderProvider>
670
+ </PaneInstanceProvider>
671
+ </AppContext>
672
+ );
673
+ }
674
+
675
+ try {
676
+ await act(async () => {
677
+ testSetup = await testRender(<ChatPaneHarness />, {
678
+ width: 90,
679
+ height: 12,
680
+ });
681
+ });
682
+
683
+ await flushFrame();
684
+ const lines = testSetup.captureCharFrame().split("\n");
685
+ const row = lines.findIndex((line) => line.includes("options"));
686
+ const col = lines[row]?.indexOf("options") ?? -1;
687
+
688
+ expect(row).toBeGreaterThanOrEqual(0);
689
+ expect(col).toBeGreaterThanOrEqual(0);
690
+
691
+ await act(async () => {
692
+ await testSetup!.mockMouse.click(col + 1, row);
693
+ await testSetup!.renderOnce();
694
+ await testSetup!.renderOnce();
695
+ });
696
+ await flushFrame();
697
+
698
+ expect(findPaneInstance(latestState.config.layout, paneInstanceId)?.settings?.channelId).toBe("options");
699
+ expect(latestState.config.pluginConfig["gloomberb-cloud"]?.lastChatChannelId).toBe("options");
700
+ expect(testSetup.captureCharFrame()).toContain("#options");
701
+ } finally {
702
+ chatController.refreshChannels = originalRefreshChannels;
703
+ chatController.refreshSession = originalRefreshSession;
704
+ chatController.refreshChannelMessages = originalRefreshChannelMessages;
705
+ installServerChannels(chatController, []);
706
+ chatController.dispose();
707
+ }
708
+ });
709
+
359
710
  test("uses the full message row width when the reply action is hidden", async () => {
360
711
  const controller = createController({
361
712
  messages: [{
@@ -637,6 +988,29 @@ describe("ChatContent", () => {
637
988
  expect(nextScrollTop).toBe(13);
638
989
  });
639
990
 
991
+ test("keeps a selected desktop message inside the scroll viewport", () => {
992
+ expect(getScrollTopForElementIntoView({
993
+ scrollTop: 120,
994
+ viewportHeight: 60,
995
+ elementTop: 170,
996
+ elementHeight: 24,
997
+ })).toBe(134);
998
+
999
+ expect(getScrollTopForElementIntoView({
1000
+ scrollTop: 120,
1001
+ viewportHeight: 60,
1002
+ elementTop: 92,
1003
+ elementHeight: 18,
1004
+ })).toBe(92);
1005
+
1006
+ expect(getScrollTopForElementIntoView({
1007
+ scrollTop: 120,
1008
+ viewportHeight: 60,
1009
+ elementTop: 132,
1010
+ elementHeight: 18,
1011
+ })).toBe(120);
1012
+ });
1013
+
640
1014
  test("up arrow still moves within a multi-line draft before handing off to message selection", async () => {
641
1015
  const controller = createController({ sessionToken: "token-123", messages: [makeMessage(1)] });
642
1016
  const sentMessages: string[] = [];
@@ -1431,13 +1805,13 @@ describe("ChatContent", () => {
1431
1805
  sessionToken: "token-123",
1432
1806
  user: { id: "u1", username: "vince", emailVerified: true },
1433
1807
  });
1434
- const openedWidgets: string[] = [];
1808
+ const openedPanes: string[] = [];
1435
1809
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
1436
1810
  state.config.disabledPlugins = [];
1437
1811
 
1438
1812
  const runtime = createTestPluginRuntime({
1439
- showWidget(paneId: string) {
1440
- openedWidgets.push(paneId);
1813
+ showPane(paneId: string) {
1814
+ openedPanes.push(paneId);
1441
1815
  },
1442
1816
  });
1443
1817
 
@@ -1480,7 +1854,7 @@ describe("ChatContent", () => {
1480
1854
  await testSetup!.renderOnce();
1481
1855
  });
1482
1856
 
1483
- expect(openedWidgets).toEqual(["chat"]);
1857
+ expect(openedPanes).toEqual(["chat"]);
1484
1858
  });
1485
1859
 
1486
1860
  test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
@@ -1500,8 +1874,8 @@ describe("ChatContent", () => {
1500
1874
  registerCommand: (command: { id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }) => {
1501
1875
  registeredCommands.push(command);
1502
1876
  },
1503
- showWidget: () => {},
1504
- hideWidget: () => {},
1877
+ showPane: () => {},
1878
+ hidePane: () => {},
1505
1879
  notify: () => {},
1506
1880
  } as any);
1507
1881
 
@@ -1518,4 +1892,48 @@ describe("ChatContent", () => {
1518
1892
  "_validate",
1519
1893
  ]);
1520
1894
  });
1895
+
1896
+ test("new chat pane opens directly on the last visited channel", async () => {
1897
+ const originalRefreshChannels = chatController.refreshChannels;
1898
+ installServerChannels(chatController);
1899
+ chatController.refreshChannels = async () => {};
1900
+ const chatTemplate = gloomberbCloudPlugin.paneTemplates?.find((template) => template.id === "new-chat-pane");
1901
+ try {
1902
+ expect(chatTemplate?.wizard).toBeUndefined();
1903
+ expect(chatTemplate?.shortcut).toMatchObject({
1904
+ prefix: "CHAT",
1905
+ argPlaceholder: "channel",
1906
+ argKind: "text",
1907
+ });
1908
+ const created = await Promise.resolve(chatTemplate?.createInstance?.({
1909
+ config: {
1910
+ pluginConfig: {
1911
+ "gloomberb-cloud": {
1912
+ lastChatChannelId: "options",
1913
+ },
1914
+ },
1915
+ },
1916
+ }));
1917
+ expect(created).toMatchObject({
1918
+ settings: { channelId: "options" },
1919
+ placement: "floating",
1920
+ });
1921
+ const createdFromShortcut = await Promise.resolve(chatTemplate?.createInstance?.({
1922
+ config: {
1923
+ pluginConfig: {
1924
+ "gloomberb-cloud": {
1925
+ lastChatChannelId: "options",
1926
+ },
1927
+ },
1928
+ },
1929
+ }, { arg: "#HELP" }));
1930
+ expect(createdFromShortcut).toMatchObject({
1931
+ settings: { channelId: "help" },
1932
+ placement: "floating",
1933
+ });
1934
+ } finally {
1935
+ chatController.refreshChannels = originalRefreshChannels;
1936
+ installServerChannels(chatController, []);
1937
+ }
1938
+ });
1521
1939
  });