gloomberb 0.6.1 → 0.7.0

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 (139) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +48 -27
  14. package/src/components/chart/static-chart-surface.tsx +28 -4
  15. package/src/components/chart/stock-chart.test.ts +0 -16
  16. package/src/components/chart/stock-chart.tsx +38 -21
  17. package/src/components/chart/time-axis-label.tsx +43 -0
  18. package/src/components/command-bar/command-bar.test.tsx +71 -8
  19. package/src/components/command-bar/command-bar.tsx +388 -713
  20. package/src/components/command-bar/confirm-body.tsx +51 -0
  21. package/src/components/command-bar/list-model.ts +77 -0
  22. package/src/components/command-bar/view-model.ts +0 -19
  23. package/src/components/command-bar/workflow-ops.ts +4 -0
  24. package/src/components/data-table-stack-view.test.tsx +39 -4
  25. package/src/components/data-table-stack-view.tsx +11 -104
  26. package/src/components/data-table-view.tsx +43 -79
  27. package/src/components/feed-data-table-stack-view.tsx +10 -17
  28. package/src/components/feed-stack-controller.ts +39 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/layout/floating-pane.tsx +30 -2
  31. package/src/components/layout/header.tsx +10 -7
  32. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  33. package/src/components/layout/pane-cycle-order.ts +32 -0
  34. package/src/components/layout/pane-footer.tsx +11 -4
  35. package/src/components/layout/pane-header.tsx +5 -5
  36. package/src/components/layout/shell.test.tsx +66 -22
  37. package/src/components/layout/shell.tsx +13 -6
  38. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  39. package/src/components/pane-settings-dialog.tsx +12 -4
  40. package/src/components/pane-template-wizard.test.tsx +3 -4
  41. package/src/components/speedometer-gauge.test.tsx +45 -0
  42. package/src/components/speedometer-gauge.tsx +558 -0
  43. package/src/components/table-view-shared.tsx +158 -0
  44. package/src/components/ticker-list-table-view.tsx +42 -72
  45. package/src/components/ui/confirm-dialog.tsx +66 -0
  46. package/src/components/ui/data-table.tsx +18 -10
  47. package/src/components/ui/index.ts +0 -1
  48. package/src/components/ui/page-stack-view.tsx +39 -10
  49. package/src/components/ui/table-layout.test.ts +16 -0
  50. package/src/components/ui/table-layout.ts +10 -3
  51. package/src/components/ui/ui.test.tsx +100 -20
  52. package/src/core/state/app-state.ts +6 -3
  53. package/src/market-data/coordinator.test.ts +1 -1
  54. package/src/market-data/coordinator.ts +0 -1
  55. package/src/news/aggregator.ts +0 -3
  56. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  57. package/src/plugins/builtin/ai/providers.ts +0 -17
  58. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  59. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  60. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  61. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  62. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  63. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  64. package/src/plugins/builtin/chat-controller.ts +75 -6
  65. package/src/plugins/builtin/chat.test.tsx +6 -6
  66. package/src/plugins/builtin/chat.tsx +29 -20
  67. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  68. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  69. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  70. package/src/plugins/builtin/correlation/index.tsx +1 -6
  71. package/src/plugins/builtin/debug.tsx +1 -3
  72. package/src/plugins/builtin/econ/index.tsx +1 -2
  73. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  74. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  75. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  76. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  77. package/src/plugins/builtin/help.test.tsx +2 -2
  78. package/src/plugins/builtin/help.tsx +2 -2
  79. package/src/plugins/builtin/holders/index.tsx +10 -15
  80. package/src/plugins/builtin/insider/index.tsx +22 -89
  81. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  82. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  83. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  84. package/src/plugins/builtin/news.tsx +4 -16
  85. package/src/plugins/builtin/notes.tsx +18 -55
  86. package/src/plugins/builtin/options.test.tsx +58 -2
  87. package/src/plugins/builtin/options.tsx +108 -49
  88. package/src/plugins/builtin/plugin-groups.ts +2 -0
  89. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  90. package/src/plugins/builtin/research/index.tsx +5 -21
  91. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  92. package/src/plugins/builtin/sec.tsx +13 -66
  93. package/src/plugins/builtin/sectors/index.tsx +1 -1
  94. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  95. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  96. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  97. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  98. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  99. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  100. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  101. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  102. package/src/plugins/ibkr/gateway-service.ts +1 -1
  103. package/src/plugins/ibkr/index.test.tsx +0 -2
  104. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  105. package/src/plugins/pane-manager.ts +0 -13
  106. package/src/plugins/plugin-runtime.test.tsx +6 -6
  107. package/src/plugins/plugin-runtime.tsx +13 -8
  108. package/src/plugins/prediction-markets/cli.ts +0 -1
  109. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  110. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  111. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  112. package/src/plugins/registry.ts +10 -39
  113. package/src/react/input.ts +1 -0
  114. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  115. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  116. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  117. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  118. package/src/renderers/electrobun/view/key-event.ts +1 -1
  119. package/src/renderers/electrobun/view/styles.css +331 -0
  120. package/src/renderers/opentui/chart-surface.tsx +179 -0
  121. package/src/renderers/opentui/start.tsx +48 -30
  122. package/src/renderers/opentui/test-utils.tsx +17 -0
  123. package/src/renderers/opentui/ui-host.tsx +5 -2
  124. package/src/sources/provider-router.ts +0 -4
  125. package/src/state/app-context.test.ts +45 -0
  126. package/src/state/use-inline-tickers.test.tsx +73 -0
  127. package/src/state/use-inline-tickers.ts +7 -1
  128. package/src/test-support/plugin-runtime.ts +3 -2
  129. package/src/theme/colors.ts +12 -3
  130. package/src/theme/themes.test.ts +23 -1
  131. package/src/theme/themes.ts +115 -0
  132. package/src/types/broker.ts +1 -1
  133. package/src/types/data-provider.ts +0 -1
  134. package/src/types/plugin.ts +0 -2
  135. package/src/ui/host.tsx +1 -0
  136. package/src/utils/back-navigation.ts +24 -1
  137. package/src/version.ts +1 -1
  138. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  139. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -83,6 +83,15 @@ class MemoryPersistence implements PluginPersistence {
83
83
  }
84
84
  }
85
85
 
86
+ class TrackingPersistence extends MemoryPersistence {
87
+ stateWrites = 0;
88
+
89
+ setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
90
+ this.stateWrites += 1;
91
+ super.setState(key, value, options);
92
+ }
93
+ }
94
+
86
95
  afterEach(() => {
87
96
  apiClient.dispose();
88
97
  apiClient.setSessionToken(null);
@@ -209,6 +218,34 @@ describe("ChatController", () => {
209
218
  })).toBeNull();
210
219
  });
211
220
 
221
+ test("defers draft persistence and subscriber sync until the user pauses or leaves", () => {
222
+ const persistence = new TrackingPersistence();
223
+ const controller = new ChatController();
224
+ const draftSnapshots: string[] = [];
225
+
226
+ controller.attachPersistence(persistence);
227
+ const unsubscribe = controller.subscribe((snapshot) => {
228
+ draftSnapshots.push(snapshot.draft);
229
+ });
230
+ draftSnapshots.length = 0;
231
+ persistence.stateWrites = 0;
232
+
233
+ controller.setDraft("h");
234
+ controller.setDraft("he");
235
+
236
+ expect(controller.getSnapshot().draft).toBe("he");
237
+ expect(draftSnapshots).toEqual([]);
238
+ expect(persistence.stateWrites).toBe(0);
239
+ expect(persistence.getState("channel:everyone", { schemaVersion: 1 })).toBeNull();
240
+
241
+ unsubscribe();
242
+ controller.dispose();
243
+
244
+ expect(persistence.getState<{ draft: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
245
+ draft: "he",
246
+ });
247
+ });
248
+
212
249
  test("pauses verification polling while the app is backgrounded", () => {
213
250
  const controller = new ChatController();
214
251
 
@@ -254,6 +291,74 @@ describe("ChatController", () => {
254
291
  expect((controller as any).wsConnection).toBeNull();
255
292
  });
256
293
 
294
+ test("runs a quiet safety refresh while the live connection is active", async () => {
295
+ const persistence = new MemoryPersistence();
296
+ const controller = new ChatController();
297
+ const originalSetInterval = globalThis.setInterval;
298
+ const originalClearInterval = globalThis.clearInterval;
299
+ const intervalCallbacks: Array<() => void> = [];
300
+ const intervalHandle = { unref: () => {} };
301
+ let cleared = false;
302
+ let getMessagesCalls = 0;
303
+ const loadingSnapshots: boolean[] = [];
304
+
305
+ persistence.setState("session", {
306
+ sessionToken: "token-123",
307
+ user: { id: "u1", username: "vince", emailVerified: true },
308
+ }, { schemaVersion: 1 });
309
+
310
+ (globalThis as any).setInterval = (callback: () => void, timeout: number) => {
311
+ expect(timeout).toBe(30_000);
312
+ intervalCallbacks.push(callback);
313
+ return intervalHandle;
314
+ };
315
+ (globalThis as any).clearInterval = (handle: unknown) => {
316
+ if (handle === intervalHandle) {
317
+ cleared = true;
318
+ }
319
+ };
320
+
321
+ try {
322
+ apiClient.getMessages = async () => {
323
+ getMessagesCalls += 1;
324
+ return [];
325
+ };
326
+ apiClient.connectChannel = () => ({
327
+ send: async () => {
328
+ throw new Error("not implemented");
329
+ },
330
+ close: () => {},
331
+ });
332
+
333
+ controller.attachPersistence(persistence);
334
+ controller.ensureConnection();
335
+ await flushMicrotasks();
336
+
337
+ expect(intervalCallbacks).toHaveLength(1);
338
+ expect(getMessagesCalls).toBe(1);
339
+ expect(controller.getSnapshot().loading).toBe(false);
340
+
341
+ controller.subscribe((snapshot) => {
342
+ loadingSnapshots.push(snapshot.loading);
343
+ });
344
+ loadingSnapshots.length = 0;
345
+
346
+ intervalCallbacks[0]!();
347
+ await flushMicrotasks();
348
+
349
+ expect(getMessagesCalls).toBe(2);
350
+ expect(loadingSnapshots).toEqual([false]);
351
+
352
+ controller.clearSession();
353
+ expect((controller as any).safetyRefreshTimer).toBeNull();
354
+ expect(cleared).toBe(true);
355
+ } finally {
356
+ controller.dispose();
357
+ (globalThis as any).setInterval = originalSetInterval;
358
+ (globalThis as any).clearInterval = originalClearInterval;
359
+ }
360
+ });
361
+
257
362
  test("keeps the cached session when session refresh fails transiently", async () => {
258
363
  const persistence = new MemoryPersistence();
259
364
  const controller = new ChatController();
@@ -16,7 +16,9 @@ const TRANSCRIPT_CACHE_POLICY = {
16
16
  staleMs: 30 * 24 * 60 * 60_000,
17
17
  expireMs: 90 * 24 * 60 * 60_000,
18
18
  };
19
+ const DRAFT_SYNC_DEBOUNCE_MS = 250;
19
20
  const VERIFICATION_POLL_MS = 5_000;
21
+ const SAFETY_REFRESH_MS = 30_000;
20
22
  const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
21
23
  const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
22
24
  const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
@@ -125,6 +127,8 @@ export class ChatController {
125
127
  private wsConnection: ChatConnection | null = null;
126
128
  private wsConnected = false;
127
129
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
130
+ private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
131
+ private draftSyncTimer: ReturnType<typeof setTimeout> | null = null;
128
132
  private openViewCount = 0;
129
133
  private pendingMessageSeq = 0;
130
134
  private notifyFn: (notification: AppNotificationRequest) => void = () => {};
@@ -212,6 +216,7 @@ export class ChatController {
212
216
  const token = apiClient.getSessionToken();
213
217
  if (!token) {
214
218
  this.stopVerificationPolling();
219
+ this.stopSafetyRefresh();
215
220
  this.wsConnection?.close();
216
221
  this.wsConnection = null;
217
222
  this.wsConnected = false;
@@ -227,6 +232,7 @@ export class ChatController {
227
232
  const session = await apiClient.getSession();
228
233
  if (!session) {
229
234
  this.stopVerificationPolling();
235
+ this.stopSafetyRefresh();
230
236
  this.wsConnection?.close();
231
237
  this.wsConnection = null;
232
238
  this.wsConnected = false;
@@ -259,23 +265,32 @@ export class ChatController {
259
265
  }
260
266
 
261
267
  this.syncVerificationPolling();
268
+ this.stopSafetyRefresh();
262
269
  this.wsConnection?.close();
263
270
  this.wsConnection = null;
264
271
  this.wsConnected = false;
265
272
  }
266
273
 
267
274
  async refreshMessages(): Promise<void> {
275
+ return this.runMessagesRefresh({ showLoading: true });
276
+ }
277
+
278
+ private async runMessagesRefresh(options: { showLoading: boolean }): Promise<void> {
268
279
  if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
269
280
 
270
- this.messagesLoading = true;
271
- this.emit();
281
+ if (options.showLoading) {
282
+ this.messagesLoading = true;
283
+ this.emit();
284
+ }
272
285
 
273
286
  const request = this.fetchMessages()
274
287
  .catch(() => {
275
288
  this.persistChannelState();
276
289
  })
277
290
  .finally(() => {
278
- this.messagesLoading = false;
291
+ if (options.showLoading) {
292
+ this.messagesLoading = false;
293
+ }
279
294
  this.refreshMessagesPromise = null;
280
295
  this.emit();
281
296
  });
@@ -309,8 +324,7 @@ export class ChatController {
309
324
  setDraft(draft: string): void {
310
325
  if (draft === this.draft) return;
311
326
  this.draft = draft;
312
- this.persistChannelState();
313
- this.emit();
327
+ this.scheduleDraftSync();
314
328
  }
315
329
 
316
330
  setReplyToId(replyToId: string | null): void {
@@ -326,11 +340,13 @@ export class ChatController {
326
340
  }
327
341
  return () => {
328
342
  this.openViewCount = Math.max(0, this.openViewCount - 1);
343
+ this.flushDraftSync();
329
344
  };
330
345
  }
331
346
 
332
347
  clearSession(): void {
333
348
  this.stopVerificationPolling();
349
+ this.stopSafetyRefresh();
334
350
  this.wsConnection?.close();
335
351
  this.wsConnection = null;
336
352
  this.wsConnected = false;
@@ -353,6 +369,7 @@ export class ChatController {
353
369
 
354
370
  reset(clearSession = false): void {
355
371
  this.stopVerificationPolling();
372
+ this.stopSafetyRefresh();
356
373
  this.wsConnection?.close();
357
374
  this.wsConnection = null;
358
375
  this.wsConnected = false;
@@ -381,7 +398,9 @@ export class ChatController {
381
398
  listeners: this.listeners.size,
382
399
  hasConnection: !!this.wsConnection,
383
400
  });
401
+ this.flushDraftSync();
384
402
  this.stopVerificationPolling();
403
+ this.stopSafetyRefresh();
385
404
  this.wsConnection?.close();
386
405
  this.wsConnection = null;
387
406
  this.wsConnected = false;
@@ -428,7 +447,12 @@ export class ChatController {
428
447
  }
429
448
 
430
449
  ensureConnection(): void {
431
- if (this.wsConnected || !this.user?.emailVerified || !apiClient.getSessionToken()) return;
450
+ if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
451
+ this.stopSafetyRefresh();
452
+ return;
453
+ }
454
+ this.startSafetyRefresh();
455
+ if (this.wsConnected) return;
432
456
  this.wsConnected = true;
433
457
 
434
458
  void this.refreshMessages().catch(() => {});
@@ -473,6 +497,24 @@ export class ChatController {
473
497
  this.verificationPollTimer = null;
474
498
  }
475
499
 
500
+ private startSafetyRefresh(): void {
501
+ if (this.safetyRefreshTimer) return;
502
+ this.safetyRefreshTimer = setInterval(() => {
503
+ if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
504
+ this.stopSafetyRefresh();
505
+ return;
506
+ }
507
+ void this.runMessagesRefresh({ showLoading: false }).catch(() => {});
508
+ }, SAFETY_REFRESH_MS);
509
+ this.safetyRefreshTimer.unref?.();
510
+ }
511
+
512
+ private stopSafetyRefresh(): void {
513
+ if (!this.safetyRefreshTimer) return;
514
+ clearInterval(this.safetyRefreshTimer);
515
+ this.safetyRefreshTimer = null;
516
+ }
517
+
476
518
  private async fetchMessages(): Promise<void> {
477
519
  const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
478
520
  const hasIncrementalCursor = !!this.lastCursor;
@@ -568,6 +610,11 @@ export class ChatController {
568
610
  }
569
611
 
570
612
  private persistChannelState(): void {
613
+ this.clearDraftSyncTimer();
614
+ this.writeChannelState();
615
+ }
616
+
617
+ private writeChannelState(): void {
571
618
  const value = {
572
619
  draft: this.draft,
573
620
  replyToId: this.replyToId,
@@ -582,6 +629,28 @@ export class ChatController {
582
629
  });
583
630
  }
584
631
 
632
+ private scheduleDraftSync(): void {
633
+ this.clearDraftSyncTimer();
634
+ this.draftSyncTimer = setTimeout(() => {
635
+ this.draftSyncTimer = null;
636
+ this.writeChannelState();
637
+ this.emit();
638
+ }, DRAFT_SYNC_DEBOUNCE_MS);
639
+ this.draftSyncTimer.unref?.();
640
+ }
641
+
642
+ private clearDraftSyncTimer(): void {
643
+ if (!this.draftSyncTimer) return;
644
+ clearTimeout(this.draftSyncTimer);
645
+ this.draftSyncTimer = null;
646
+ }
647
+
648
+ private flushDraftSync(): void {
649
+ if (!this.draftSyncTimer) return;
650
+ this.clearDraftSyncTimer();
651
+ this.writeChannelState();
652
+ }
653
+
585
654
  private persistTranscript(): void {
586
655
  if (this.messages.length === 0) {
587
656
  this.persistence?.deleteResource(TRANSCRIPT_KIND, "everyone", { sourceKey: TRANSCRIPT_SOURCE });
@@ -1431,13 +1431,13 @@ describe("ChatContent", () => {
1431
1431
  sessionToken: "token-123",
1432
1432
  user: { id: "u1", username: "vince", emailVerified: true },
1433
1433
  });
1434
- const openedWidgets: string[] = [];
1434
+ const openedPanes: string[] = [];
1435
1435
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
1436
1436
  state.config.disabledPlugins = [];
1437
1437
 
1438
1438
  const runtime = createTestPluginRuntime({
1439
- showWidget(paneId: string) {
1440
- openedWidgets.push(paneId);
1439
+ showPane(paneId: string) {
1440
+ openedPanes.push(paneId);
1441
1441
  },
1442
1442
  });
1443
1443
 
@@ -1480,7 +1480,7 @@ describe("ChatContent", () => {
1480
1480
  await testSetup!.renderOnce();
1481
1481
  });
1482
1482
 
1483
- expect(openedWidgets).toEqual(["chat"]);
1483
+ expect(openedPanes).toEqual(["chat"]);
1484
1484
  });
1485
1485
 
1486
1486
  test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
@@ -1500,8 +1500,8 @@ describe("ChatContent", () => {
1500
1500
  registerCommand: (command: { id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }) => {
1501
1501
  registeredCommands.push(command);
1502
1502
  },
1503
- showWidget: () => {},
1504
- hideWidget: () => {},
1503
+ showPane: () => {},
1504
+ hidePane: () => {},
1505
1505
  notify: () => {},
1506
1506
  } as any);
1507
1507
 
@@ -222,6 +222,11 @@ export function ChatContent({
222
222
  }: ChatContentProps) {
223
223
  const dispatch = useAppDispatch();
224
224
  const initialSnapshot = controller.getSnapshot();
225
+ const { nativePaneChrome } = useUiCapabilities();
226
+ const contentWidth = Math.max(width - 2, 1);
227
+ const composerPrefixWidth = nativePaneChrome ? 0 : 3;
228
+ const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
229
+ const inputValueRef = useRef(initialSnapshot.draft);
225
230
  const [messages, setMessages] = useState<ChatMessage[]>(initialSnapshot.messages);
226
231
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
227
232
  const [user, setUser] = useState<{ id: string; username: string; emailVerified: boolean } | null>(initialSnapshot.user);
@@ -229,15 +234,13 @@ export function ChatContent({
229
234
  const [loadingOlderMessages, setLoadingOlderMessages] = useState(initialSnapshot.loadingOlderMessages);
230
235
  const [hasOlderMessages, setHasOlderMessages] = useState(initialSnapshot.hasOlderMessages);
231
236
  const [inputFocused, setInputFocused] = useState(false);
232
- const [inputValue, setInputValue] = useState(initialSnapshot.draft);
237
+ const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, composerTextWidth));
233
238
  const [selectedIdx, setSelectedIdx] = useState(-1);
234
239
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
235
240
  const [followMessages, setFollowMessages] = useState(true);
236
- const contentWidth = Math.max(width - 2, 1);
237
241
  const [replyTo, setReplyTo] = useState<ChatMessage | null>(() => (
238
242
  initialSnapshot.replyToId ? initialSnapshot.messages.find((message) => message.id === initialSnapshot.replyToId) ?? null : null
239
243
  ));
240
- const { nativePaneChrome } = useUiCapabilities();
241
244
  const inputRef = useRef<TextareaRenderable>(null);
242
245
  const scrollRef = useRef<ScrollBoxRenderable>(null);
243
246
  const applyingExternalDraftRef = useRef(false);
@@ -250,9 +253,6 @@ export function ChatContent({
250
253
  const canSend = !!user?.emailVerified;
251
254
  const nativeComposerTopGap = nativePaneChrome && canSend ? NATIVE_CHAT_COMPOSER_TOP_GAP_PX : 0;
252
255
  const messageBodyWidth = Math.max(contentWidth - 4, 1);
253
- const composerPrefixWidth = nativePaneChrome ? 0 : 3;
254
- const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
255
- const composerRows = estimateComposerHeight(inputValue, composerTextWidth);
256
256
  const composerHeight = canSend
257
257
  ? nativePaneChrome
258
258
  ? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
@@ -260,11 +260,19 @@ export function ChatContent({
260
260
  : 0;
261
261
  const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
262
262
  const stickyTranscript = followMessages && !selectionActive;
263
+ const updateComposerRows = useCallback((draft: string) => {
264
+ const nextRows = estimateComposerHeight(draft, composerTextWidth);
265
+ setComposerRows((current) => (current === nextRows ? current : nextRows));
266
+ }, [composerTextWidth]);
263
267
 
264
268
  useEffect(() => {
265
269
  return controller.attachView();
266
270
  }, [controller]);
267
271
 
272
+ useEffect(() => {
273
+ updateComposerRows(inputValueRef.current);
274
+ }, [updateComposerRows]);
275
+
268
276
  useEffect(() => {
269
277
  const unsubscribe = controller.subscribe((snapshot) => {
270
278
  setMessages(snapshot.messages);
@@ -273,7 +281,10 @@ export function ChatContent({
273
281
  setLoading(snapshot.loading);
274
282
  setLoadingOlderMessages(snapshot.loadingOlderMessages);
275
283
  setHasOlderMessages(snapshot.hasOlderMessages);
276
- setInputValue((current) => (current === snapshot.draft ? current : snapshot.draft));
284
+ if (inputValueRef.current !== snapshot.draft) {
285
+ inputValueRef.current = snapshot.draft;
286
+ updateComposerRows(snapshot.draft);
287
+ }
277
288
  const textarea = inputRef.current;
278
289
  if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
279
290
  applyingExternalDraftRef.current = true;
@@ -288,12 +299,10 @@ export function ChatContent({
288
299
  void controller.refreshSession().catch(() => {});
289
300
  void controller.refreshMessages().catch(() => {});
290
301
  return unsubscribe;
291
- }, [controller]);
302
+ }, [controller, updateComposerRows]);
292
303
 
293
304
  const { catalog, openTicker } = useInlineTickers(messages.map((message) => message.content));
294
305
 
295
- const inputValueRef = useRef(inputValue);
296
- inputValueRef.current = inputValue;
297
306
  const replyToRef = useRef(replyTo);
298
307
  replyToRef.current = replyTo;
299
308
 
@@ -423,9 +432,9 @@ export function ChatContent({
423
432
  const commitLocalDraft = useCallback((draft: string) => {
424
433
  if (applyingExternalDraftRef.current) return;
425
434
  inputValueRef.current = draft;
426
- setInputValue((current) => (current === draft ? current : draft));
435
+ updateComposerRows(draft);
427
436
  controller.setDraft(draft);
428
- }, [controller]);
437
+ }, [controller, updateComposerRows]);
429
438
 
430
439
  useEffect(() => {
431
440
  const textarea = inputRef.current;
@@ -834,7 +843,7 @@ export function ChatContent({
834
843
 
835
844
  <MessageComposer
836
845
  inputRef={inputRef}
837
- initialValue={inputValue}
846
+ initialValue={inputValueRef.current}
838
847
  focused={inputFocused && focused}
839
848
  placeholder={inputPlaceholder}
840
849
  terminalPrefix=" > "
@@ -882,7 +891,7 @@ export function ChatContent({
882
891
  );
883
892
  }
884
893
 
885
- export function ChatPane({ focused, width, height, close }: PaneProps) {
894
+ function ChatPane({ focused, width, height, close }: PaneProps) {
886
895
  return (
887
896
  <ChatContent
888
897
  width={width}
@@ -894,7 +903,7 @@ export function ChatPane({ focused, width, height, close }: PaneProps) {
894
903
  }
895
904
 
896
905
  export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
897
- const { showWidget } = usePluginAppActions();
906
+ const { showPane } = usePluginAppActions();
898
907
  const cloudPluginDisabled = useAppSelector((state) => state.config.disabledPlugins.includes("gloomberb-cloud"));
899
908
  const initialSnapshot = controller.getSnapshot();
900
909
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
@@ -905,7 +914,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
905
914
  const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
906
915
  event?.preventDefault?.();
907
916
  event?.stopPropagation?.();
908
- showWidget("chat");
917
+ showPane("chat");
909
918
  };
910
919
 
911
920
  useEffect(() => {
@@ -1056,9 +1065,9 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1056
1065
  execute: () => {
1057
1066
  const registry = getSharedRegistry();
1058
1067
  if (registry?.isPaneFloating("chat")) {
1059
- ctx.hideWidget("chat");
1068
+ ctx.hidePane("chat");
1060
1069
  } else {
1061
- ctx.showWidget("chat");
1070
+ ctx.showPane("chat");
1062
1071
  }
1063
1072
  },
1064
1073
  });
@@ -1086,7 +1095,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1086
1095
  }
1087
1096
  chatController.clearSession();
1088
1097
  await chatController.refreshSession();
1089
- ctx.showWidget("chat");
1098
+ ctx.showPane("chat");
1090
1099
  },
1091
1100
  });
1092
1101
 
@@ -1122,7 +1131,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
1122
1131
  await apiClient.sendVerification();
1123
1132
  chatController.clearSession();
1124
1133
  await chatController.refreshSession();
1125
- ctx.showWidget("chat");
1134
+ ctx.showPane("chat");
1126
1135
  },
1127
1136
  });
1128
1137
 
@@ -546,7 +546,6 @@ function TweetSearchTable({
546
546
  emptyContent={emptyContent}
547
547
  emptyStateTitle={loading ? "Loading tweets..." : error ?? "No tweets"}
548
548
  emptyStateHint={data?.query}
549
- showHorizontalScrollbar={false}
550
549
  />
551
550
  );
552
551
  }
@@ -109,7 +109,7 @@ function createRegistrySpy(spy: { selected: string[]; focused: string[] }): Plug
109
109
  } as unknown as PluginRegistry;
110
110
  }
111
111
 
112
- function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): PluginRuntimeAccess {
112
+ function createRuntimeSpy(spy: { selected: string[]; focused: string[]; settings?: string[] }): PluginRuntimeAccess {
113
113
  return createTestPluginRuntime({
114
114
  pinTicker: (symbol: string) => {
115
115
  spy.selected.push(symbol);
@@ -119,6 +119,9 @@ function createRuntimeSpy(spy: { selected: string[]; focused: string[] }): Plugi
119
119
  spy.selected.push(symbol);
120
120
  spy.focused.push("ticker-detail");
121
121
  },
122
+ openPaneSettings: (paneId) => {
123
+ spy.settings?.push(paneId ?? "");
124
+ },
122
125
  });
123
126
  }
124
127
 
@@ -196,7 +199,7 @@ async function mountComparisonHarness(
196
199
  settings: Record<string, unknown>,
197
200
  tickers: TickerRecord[],
198
201
  financials: Array<[string, TickerFinancials]>,
199
- spy: { selected: string[]; focused: string[] } = { selected: [], focused: [] },
202
+ spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
200
203
  ) {
201
204
  actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
202
205
  testSetup = await createTestRenderer({ width: 120, height: 20 });
@@ -251,6 +254,21 @@ describe("comparisonChartPlugin", () => {
251
254
  });
252
255
  });
253
256
 
257
+ test("requires at least two symbols when creating a comparison pane", async () => {
258
+ const template = comparisonChartPlugin.paneTemplates?.[0]!;
259
+ const context = {
260
+ config: createDefaultConfig("/tmp/gloomberb-compare"),
261
+ layout: { dockRoot: null, instances: [], floating: [], detached: [] },
262
+ focusedPaneId: null,
263
+ activeTicker: null,
264
+ activeCollectionId: null,
265
+ };
266
+
267
+ expect(template.canCreate?.(context, { arg: "AMD" })).toBe(true);
268
+ expect(template.canCreate?.(context, { arg: "AMD", symbols: ["AMD"] })).toBe(false);
269
+ expect(await template.createInstance?.(context, { symbols: ["AMD"] })).toBeNull();
270
+ });
271
+
254
272
  test("renders one shared overlay chart with the mixed-currency warning", async () => {
255
273
  const provider = createProvider({
256
274
  AAPL: [100, 102, 104, 106],
@@ -284,6 +302,7 @@ describe("comparisonChartPlugin", () => {
284
302
  expect(frame).toContain("2:1W");
285
303
  expect(frame).toContain("1D");
286
304
  expect(frame).toContain("view:");
305
+ expect(frame).toContain("[t]ickers");
287
306
  expect(frame).toContain("[m]ode");
288
307
  expect(frame).toContain("[r]es");
289
308
  expect(frame).not.toContain("[up/down]legend");
@@ -364,4 +383,36 @@ describe("comparisonChartPlugin", () => {
364
383
  expect(spy.selected).toEqual(["MSFT"]);
365
384
  expect(spy.focused).toEqual(["ticker-detail"]);
366
385
  });
386
+
387
+ test("opens comparison ticker settings from the t shortcut", async () => {
388
+ const spy = { selected: [] as string[], focused: [] as string[], settings: [] as string[] };
389
+ const provider = createProvider({
390
+ AAPL: [100, 102, 104],
391
+ MSFT: [200, 202, 204],
392
+ }, {
393
+ AAPL: "USD",
394
+ MSFT: "USD",
395
+ });
396
+ setSharedMarketDataForTests(provider);
397
+ sharedCoordinator = new MarketDataCoordinator(provider);
398
+ setSharedMarketDataCoordinator(sharedCoordinator);
399
+ setSharedRegistryForTests(createRegistrySpy(spy));
400
+
401
+ await mountComparisonHarness({
402
+ axisMode: "percent",
403
+ symbols: ["AAPL", "MSFT"],
404
+ symbolsText: "AAPL, MSFT",
405
+ }, [
406
+ makeTicker("AAPL", "USD"),
407
+ makeTicker("MSFT", "USD"),
408
+ ], [
409
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
410
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
411
+ ], spy);
412
+
413
+ await flushFrames();
414
+ await pressComparisonInput(() => testSetup!.mockInput.pressKey("t"));
415
+
416
+ expect(spy.settings).toEqual([TEST_PANE_ID]);
417
+ });
367
418
  });
@@ -8,7 +8,7 @@ import {
8
8
  DEFAULT_COMPARISON_CHART_RESOLUTION,
9
9
  normalizeChartResolution,
10
10
  } from "../../components/chart/chart-resolution";
11
- import { usePluginTickerActions } from "../../plugins/plugin-runtime";
11
+ import { usePluginAppActions, usePluginTickerActions } from "../../plugins/plugin-runtime";
12
12
  import { usePaneInstance } from "../../state/app-context";
13
13
  import { colors } from "../../theme/colors";
14
14
  import type { GloomPlugin, PaneProps, PaneSettingsDef } from "../../types/plugin";
@@ -16,6 +16,7 @@ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } fro
16
16
 
17
17
  export const COMPARISON_CHART_PANE_ID = "comparison-chart";
18
18
  export const COMPARISON_CHART_TEMPLATE_ID = "comparison-chart-pane";
19
+ export const MIN_COMPARISON_CHART_SYMBOLS = 2;
19
20
 
20
21
  interface ComparisonChartPaneSettings {
21
22
  axisMode: ChartAxisMode;
@@ -77,7 +78,7 @@ function buildComparisonChartSettingsDef(): PaneSettingsDef {
77
78
  {
78
79
  key: "symbolsText",
79
80
  label: "Tickers",
80
- description: `Enter 1-${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
81
+ description: `Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
81
82
  type: "text",
82
83
  placeholder: "AAPL, MSFT, NVDA",
83
84
  },
@@ -102,12 +103,16 @@ export function buildComparisonChartPaneTitle(symbols: string[]): string {
102
103
  }
103
104
  function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
104
105
  const { navigateTicker } = usePluginTickerActions();
106
+ const { openPaneSettings } = usePluginAppActions();
105
107
  const pane = usePaneInstance();
106
108
  const settings = useMemo(() => getComparisonChartPaneSettings(pane?.settings), [pane?.settings]);
107
109
 
108
110
  const openTicker = useCallback((symbol: string) => {
109
111
  navigateTicker(symbol);
110
112
  }, [navigateTicker]);
113
+ const editTickers = useCallback(() => {
114
+ openPaneSettings(paneId);
115
+ }, [openPaneSettings, paneId]);
111
116
 
112
117
  if (settings.symbols.length === 0) {
113
118
  return (
@@ -127,6 +132,7 @@ function ComparisonChartPane({ paneId, focused, width, height }: PaneProps) {
127
132
  symbols={settings.symbols}
128
133
  axisMode={settings.axisMode}
129
134
  onOpenSymbol={openTicker}
135
+ onEditTickers={editTickers}
130
136
  />
131
137
  </Box>
132
138
  );
@@ -162,15 +168,15 @@ export const comparisonChartPlugin: GloomPlugin = {
162
168
  label: "Comparison Tickers",
163
169
  placeholder: "AAPL, MSFT, NVDA",
164
170
  body: [
165
- `Enter 1-${MAX_TICKER_LIST_SIZE} ticker symbols separated by commas.`,
171
+ `Enter ${MIN_COMPARISON_CHART_SYMBOLS}-${MAX_TICKER_LIST_SIZE} ticker symbols separated by commas.`,
166
172
  ],
167
173
  type: "text",
168
174
  },
169
175
  ],
170
- canCreate: (_context, options) => !options?.symbols || options.symbols.length > 0,
176
+ canCreate: (_context, options) => !options?.symbols || options.symbols.length >= MIN_COMPARISON_CHART_SYMBOLS,
171
177
  createInstance: (_context, options) => {
172
178
  const symbols = options?.symbols ?? [];
173
- if (symbols.length === 0) return null;
179
+ if (symbols.length < MIN_COMPARISON_CHART_SYMBOLS) return null;
174
180
  return {
175
181
  title: buildComparisonChartPaneTitle(symbols),
176
182
  placement: "floating",