gloomberb 0.3.7 → 0.4.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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -19,7 +19,7 @@ interface ChatContentProps {
19
19
  close?: () => void;
20
20
  controller?: Pick<
21
21
  ChatController,
22
- "getSnapshot" | "refreshSession" | "send" | "setDraft" | "setReplyToId" | "subscribe"
22
+ "attachView" | "getSnapshot" | "refreshMessages" | "refreshSession" | "send" | "setDraft" | "setReplyToId" | "subscribe"
23
23
  >;
24
24
  }
25
25
 
@@ -34,16 +34,27 @@ function estimateWrappedLineCount(text: string, width: number) {
34
34
  ), 0);
35
35
  }
36
36
 
37
- function estimateMessageHeight(message: ChatMessage, width: number) {
37
+ const MESSAGE_GROUP_THRESHOLD_MS = 5 * 60 * 1000;
38
+
39
+ function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
40
+ if (index === 0) return false;
41
+ const prev = messages[index - 1]!;
42
+ const curr = messages[index]!;
43
+ if (prev.user.id !== curr.user.id) return false;
44
+ return new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() < MESSAGE_GROUP_THRESHOLD_MS;
45
+ }
46
+
47
+ function estimateMessageHeight(message: ChatMessage, width: number, grouped = false) {
38
48
  const contentLineWidth = Math.max(width - 4, 1);
39
49
  const normalizedContent = message.content.replace(/\$[A-Z][A-Z0-9.-]{0,9}/g, (match) => ` ${match.slice(1)} +0% `);
40
- return 1 + (message.replyTo ? 1 : 0) + estimateWrappedLineCount(normalizedContent, contentLineWidth);
50
+ const headerHeight = grouped ? 0 : 1;
51
+ return headerHeight + (message.replyTo ? 1 : 0) + estimateWrappedLineCount(normalizedContent, contentLineWidth);
41
52
  }
42
53
 
43
54
  function getMessageTopOffset(messages: ChatMessage[], index: number, width: number) {
44
55
  let offset = 0;
45
56
  for (let i = 0; i < index; i += 1) {
46
- offset += estimateMessageHeight(messages[i]!, width);
57
+ offset += estimateMessageHeight(messages[i]!, width, isGroupedWithPrevious(messages, i));
47
58
  }
48
59
  return offset;
49
60
  }
@@ -53,6 +64,42 @@ function scrollToBottom(scrollBox: ScrollBoxRenderable | null) {
53
64
  scrollBox.scrollTo(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height));
54
65
  }
55
66
 
67
+ function openAuthCommand(query: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) {
68
+ event?.preventDefault?.();
69
+ event?.stopPropagation?.();
70
+ getSharedRegistry()?.openCommandBarFn(query);
71
+ }
72
+
73
+ function InlineAuthActions({ showSignup = true }: { showSignup?: boolean }) {
74
+ const [hoveredAction, setHoveredAction] = useState<"login" | "signup" | null>(null);
75
+
76
+ return (
77
+ <box flexDirection="row">
78
+ <box
79
+ backgroundColor={hoveredAction === "login" ? hoverBg() : undefined}
80
+ onMouseMove={() => setHoveredAction("login")}
81
+ onMouseOut={() => setHoveredAction((current) => (current === "login" ? null : current))}
82
+ onMouseDown={(event: any) => openAuthCommand("Login", event)}
83
+ >
84
+ <text fg={hoveredAction === "login" ? colors.text : colors.textDim}> Login </text>
85
+ </box>
86
+ {showSignup && (
87
+ <>
88
+ <text fg={colors.textDim}>/</text>
89
+ <box
90
+ backgroundColor={hoveredAction === "signup" ? hoverBg() : undefined}
91
+ onMouseMove={() => setHoveredAction("signup")}
92
+ onMouseOut={() => setHoveredAction((current) => (current === "signup" ? null : current))}
93
+ onMouseDown={(event: any) => openAuthCommand("Sign Up", event)}
94
+ >
95
+ <text fg={hoveredAction === "signup" ? colors.text : colors.textDim}> Sign Up </text>
96
+ </box>
97
+ </>
98
+ )}
99
+ </box>
100
+ );
101
+ }
102
+
56
103
  export function ChatContent({
57
104
  width,
58
105
  height,
@@ -77,6 +124,11 @@ export function ChatContent({
77
124
  ));
78
125
  const inputRef = useRef<InputRenderable>(null);
79
126
  const scrollRef = useRef<ScrollBoxRenderable>(null);
127
+ const canSend = !!user?.emailVerified;
128
+
129
+ useEffect(() => {
130
+ return controller.attachView();
131
+ }, [controller]);
80
132
 
81
133
  useEffect(() => {
82
134
  const unsubscribe = controller.subscribe((snapshot) => {
@@ -91,6 +143,7 @@ export function ChatContent({
91
143
  });
92
144
 
93
145
  void controller.refreshSession().catch(() => {});
146
+ void controller.refreshMessages().catch(() => {});
94
147
  return unsubscribe;
95
148
  }, [controller]);
96
149
 
@@ -121,11 +174,16 @@ export function ChatContent({
121
174
  const content = inputValueRef.current.trim();
122
175
  if (!content) return;
123
176
  controller.send(content, replyToRef.current?.id);
124
- setReplyTo(null);
125
177
  setSelectedIdx(-1);
126
178
  setFollowMessages(true);
127
179
  }, [controller]);
128
180
 
181
+ useEffect(() => {
182
+ if (!canSend && inputFocused) {
183
+ blurInput();
184
+ }
185
+ }, [blurInput, canSend, inputFocused]);
186
+
129
187
  useEffect(() => {
130
188
  if (!focused && inputFocused) {
131
189
  blurInput();
@@ -138,6 +196,12 @@ export function ChatContent({
138
196
  }
139
197
  }, [focused, inputFocused]);
140
198
 
199
+ useEffect(() => {
200
+ if (canSend || !replyTo) return;
201
+ setReplyTo(null);
202
+ controller.setReplyToId(null);
203
+ }, [canSend, controller, replyTo]);
204
+
141
205
  useKeyboard((event) => {
142
206
  if (!focused) return;
143
207
 
@@ -161,7 +225,7 @@ export function ChatContent({
161
225
  return;
162
226
  }
163
227
 
164
- if (event.name === "return" || event.name === "i") {
228
+ if ((event.name === "return" || event.name === "i") && canSend) {
165
229
  focusInput();
166
230
  return;
167
231
  }
@@ -187,7 +251,7 @@ export function ChatContent({
187
251
  return;
188
252
  }
189
253
 
190
- if (event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
254
+ if (canSend && event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
191
255
  const nextReplyTo = messages[selectedIdx] ?? null;
192
256
  setReplyTo(nextReplyTo);
193
257
  controller.setReplyToId(nextReplyTo?.id ?? null);
@@ -207,7 +271,7 @@ export function ChatContent({
207
271
  queueMicrotask(() => scrollToBottom(scrollRef.current));
208
272
  return;
209
273
  }
210
- }, [blurInput, close, controller, focusInput, focused, inputFocused, messages, replyTo, selectedIdx]);
274
+ }, [blurInput, canSend, close, controller, focusInput, focused, inputFocused, messages, replyTo, selectedIdx]);
211
275
 
212
276
  useEffect(() => {
213
277
  if (selectedIdx < messages.length) return;
@@ -220,7 +284,7 @@ export function ChatContent({
220
284
  if (followMessages) return;
221
285
  if (selectedIdx < 0 || selectedIdx >= messages.length) return;
222
286
  const top = getMessageTopOffset(messages, selectedIdx, contentWidth);
223
- const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth);
287
+ const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth, isGroupedWithPrevious(messages, selectedIdx));
224
288
  const viewportHeight = Math.max(sb.viewport.height, 1);
225
289
  if (top < sb.scrollTop) {
226
290
  sb.scrollTo(top);
@@ -229,13 +293,13 @@ export function ChatContent({
229
293
  }
230
294
  }, [contentWidth, followMessages, messages, selectedIdx]);
231
295
 
232
- const replyBarHeight = replyTo ? 1 : 0;
233
- const inputAreaHeight = 1 + replyBarHeight;
296
+ const replyBarHeight = canSend && replyTo ? 1 : 0;
297
+ const inputAreaHeight = canSend ? 1 + replyBarHeight : 2;
234
298
  const headerHeight = 1;
235
299
  const separatorHeight = 1;
236
300
  const messageAreaHeight = Math.max(1, height - headerHeight - separatorHeight - inputAreaHeight - 1);
237
301
 
238
- if (loading) {
302
+ if (loading && messages.length === 0) {
239
303
  return (
240
304
  <box flexGrow={1} alignItems="center" justifyContent="center">
241
305
  <text fg={colors.textDim}>Loading...</text>
@@ -243,39 +307,6 @@ export function ChatContent({
243
307
  );
244
308
  }
245
309
 
246
- if (!user && !hasSavedSession) {
247
- return (
248
- <box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
249
- <text fg={colors.textDim}>Not logged in.</text>
250
- <text fg={colors.textDim}> </text>
251
- <text fg={colors.text}>Press Ctrl+P and search "Login" or "Sign Up"</text>
252
- <text fg={colors.textDim}>to start chatting.</text>
253
- </box>
254
- );
255
- }
256
-
257
- if (!user && hasSavedSession) {
258
- return (
259
- <box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
260
- <text fg={colors.positive}>Saved login found.</text>
261
- <text fg={colors.textDim}> </text>
262
- <text fg={colors.text}>Reconnect to refresh your Gloomberb Cloud session.</text>
263
- <text fg={colors.textDim}>Your cached transcript is still available locally.</text>
264
- </box>
265
- );
266
- }
267
-
268
- if (!user.emailVerified) {
269
- return (
270
- <box flexGrow={1} alignItems="center" justifyContent="center" flexDirection="column">
271
- <text fg={colors.positive}>Check your email to unlock Gloomberb Cloud.</text>
272
- <text fg={colors.textDim}> </text>
273
- <text fg={colors.text}>Near real-time data and chat are free, but require email verification.</text>
274
- <text fg={colors.textDim}>Use Ctrl+P and search "Resend Verification Email" if you need another link.</text>
275
- </box>
276
- );
277
- }
278
-
279
310
  return (
280
311
  <box flexDirection="column" width={width} height={height}>
281
312
  <box height={1} width={contentWidth} flexDirection="row">
@@ -305,6 +336,13 @@ export function ChatContent({
305
336
  const isSelected = index === selectedIdx;
306
337
  const isHovered = index === hoveredIdx && !isSelected;
307
338
  const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
339
+ const grouped = isGroupedWithPrevious(messages, index);
340
+ const isSending = msg.clientStatus === "sending";
341
+ const hasFailed = msg.clientStatus === "failed";
342
+ const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
343
+ const headerStatusColor = isSending ? colors.textDim : hasFailed ? colors.negative : colors.textMuted;
344
+ const authorAttributes = (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD;
345
+ const bodyColor = hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
308
346
 
309
347
  return (
310
348
  <box
@@ -329,18 +367,20 @@ export function ChatContent({
329
367
  </text>
330
368
  </box>
331
369
  )}
332
- <box flexDirection="row" height={1} paddingLeft={1}>
333
- <text fg={colors.positive} attributes={TextAttributes.BOLD}>
334
- {msg.user.username ?? "anon"}
335
- </text>
336
- <text fg={colors.textMuted}> ({formatTimeAgo(msg.createdAt)})</text>
337
- </box>
370
+ {!grouped && (
371
+ <box flexDirection="row" height={1} paddingLeft={1}>
372
+ <text fg={colors.positive} attributes={authorAttributes}>
373
+ {msg.user.username ?? "anon"}
374
+ </text>
375
+ <text fg={headerStatusColor}> ({headerStatus})</text>
376
+ </box>
377
+ )}
338
378
  <box paddingLeft={3}>
339
379
  <TickerBadgeText
340
380
  text={msg.content}
341
381
  lineWidth={contentWidth - 4}
342
382
  catalog={catalog}
343
- textColor={colors.text}
383
+ textColor={bodyColor}
344
384
  openTicker={openTicker}
345
385
  />
346
386
  </box>
@@ -353,35 +393,58 @@ export function ChatContent({
353
393
  <text fg={colors.border}>{"-".repeat(contentWidth)}</text>
354
394
  </box>
355
395
 
356
- {replyTo && (
357
- <box height={1} width={contentWidth} flexDirection="row">
358
- <text fg={colors.textMuted}> replying to </text>
359
- <text fg={colors.positive}>{replyTo.user.username}</text>
360
- <box flexGrow={1} />
361
- <text fg={colors.textDim}>[Esc cancel]</text>
396
+ {canSend ? (
397
+ <>
398
+ {replyTo && (
399
+ <box height={1} width={contentWidth} flexDirection="row">
400
+ <text fg={colors.textMuted}> replying to </text>
401
+ <text fg={colors.positive}>{replyTo.user.username}</text>
402
+ <box flexGrow={1} />
403
+ <text fg={colors.textDim}>[Esc cancel]</text>
404
+ </box>
405
+ )}
406
+
407
+ <box height={1} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
408
+ <text fg={colors.textDim}> {">"} </text>
409
+ <input
410
+ ref={inputRef}
411
+ value={inputValue}
412
+ focused={inputFocused && focused}
413
+ placeholder="Type a message..."
414
+ placeholderColor={colors.textMuted}
415
+ textColor={colors.text}
416
+ backgroundColor={colors.bg}
417
+ flexGrow={1}
418
+ onInput={updateDraft}
419
+ onChange={updateDraft}
420
+ onSubmit={() => {
421
+ if (inputValueRef.current.trim()) {
422
+ sendMessage();
423
+ }
424
+ }}
425
+ />
426
+ </box>
427
+ </>
428
+ ) : (
429
+ <box width={contentWidth} height={2} flexDirection="column">
430
+ {!user && !hasSavedSession ? (
431
+ <>
432
+ <text fg={colors.textDim}>Read-only chat. Log in or sign up to send.</text>
433
+ <InlineAuthActions />
434
+ </>
435
+ ) : !user ? (
436
+ <>
437
+ <text fg={colors.positive}>Saved login found. Log in again to send.</text>
438
+ <InlineAuthActions showSignup={false} />
439
+ </>
440
+ ) : (
441
+ <>
442
+ <text fg={colors.positive}>Verify your email to send messages.</text>
443
+ <text fg={colors.textDim}>Ctrl+P: Resend Verification Email</text>
444
+ </>
445
+ )}
362
446
  </box>
363
447
  )}
364
-
365
- <box height={1} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
366
- <text fg={colors.textDim}> {">"} </text>
367
- <input
368
- ref={inputRef}
369
- value={inputValue}
370
- focused={inputFocused && focused}
371
- placeholder="Type a message..."
372
- placeholderColor={colors.textMuted}
373
- textColor={colors.text}
374
- backgroundColor={colors.bg}
375
- flexGrow={1}
376
- onInput={updateDraft}
377
- onChange={updateDraft}
378
- onSubmit={() => {
379
- if (inputValueRef.current.trim()) {
380
- sendMessage();
381
- }
382
- }}
383
- />
384
- </box>
385
448
  </box>
386
449
  );
387
450
  }
@@ -398,17 +461,24 @@ export function ChatPane({ focused, width, height, close }: PaneProps) {
398
461
  }
399
462
 
400
463
  export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
401
- const registry = getSharedRegistry();
402
464
  const { state } = useAppState();
403
465
  const initialSnapshot = controller.getSnapshot();
404
- const [hoveredAction, setHoveredAction] = useState<"login" | "signup" | null>(null);
405
466
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
406
467
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
468
+ const [unreadMentionCount, setUnreadMentionCount] = useState(initialSnapshot.unreadMentionCount);
469
+ const [hovered, setHovered] = useState(false);
470
+
471
+ const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
472
+ event?.preventDefault?.();
473
+ event?.stopPropagation?.();
474
+ getSharedRegistry()?.showWidget("chat");
475
+ };
407
476
 
408
477
  useEffect(() => {
409
478
  const unsubscribe = controller.subscribe((snapshot) => {
410
479
  setUsername(snapshot.user?.username ?? null);
411
480
  setHasSavedSession(snapshot.hasSavedSession);
481
+ setUnreadMentionCount(snapshot.unreadMentionCount);
412
482
  });
413
483
  void controller.refreshSession().catch(() => {});
414
484
  return unsubscribe;
@@ -416,47 +486,34 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
416
486
 
417
487
  if (state.config.disabledPlugins.includes("gloomberb-cloud")) return null;
418
488
 
419
- const openAuthCommand = (query: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
420
- event?.preventDefault?.();
421
- event?.stopPropagation?.();
422
- registry?.openCommandBarFn(query);
423
- };
424
-
425
489
  return (
426
490
  <box flexDirection="row" paddingRight={1}>
427
491
  {!username && !hasSavedSession ? (
428
492
  <>
429
493
  <text fg={colors.textDim}>☁ </text>
430
- <box
431
- backgroundColor={hoveredAction === "login" ? hoverBg() : undefined}
432
- onMouseMove={() => setHoveredAction("login")}
433
- onMouseOut={() => setHoveredAction((current) => (current === "login" ? null : current))}
434
- onMouseDown={(event: any) => openAuthCommand("Login", event)}
435
- >
436
- <text fg={hoveredAction === "login" ? colors.text : colors.textDim}> Login </text>
437
- </box>
438
- <text fg={colors.textDim}>/</text>
439
- <box
440
- backgroundColor={hoveredAction === "signup" ? hoverBg() : undefined}
441
- onMouseMove={() => setHoveredAction("signup")}
442
- onMouseOut={() => setHoveredAction((current) => (current === "signup" ? null : current))}
443
- onMouseDown={(event: any) => openAuthCommand("Sign Up", event)}
444
- >
445
- <text fg={hoveredAction === "signup" ? colors.text : colors.textDim}> Sign Up </text>
446
- </box>
494
+ <InlineAuthActions />
447
495
  </>
448
496
  ) : (
449
- <text fg={colors.textDim}>
450
- <span fg={colors.positive}>@</span>
451
- {username ? (
452
- <>
453
- {" "}
454
- <span fg={colors.positive}>{username}</span>
455
- {" "}
456
- </>
457
- ) : " "}
458
- <span fg={colors.text}>Shift+C</span> cloud
459
- </text>
497
+ <box
498
+ flexDirection="row"
499
+ backgroundColor={hovered ? hoverBg() : undefined}
500
+ onMouseMove={() => setHovered(true)}
501
+ onMouseOut={() => setHovered(false)}
502
+ onMouseDown={openChat}
503
+ >
504
+ <text fg={unreadMentionCount > 0 ? colors.text : colors.textDim}>
505
+ <span fg={colors.positive}>@</span>
506
+ {username ? (
507
+ <>
508
+ {" "}
509
+ <span fg={colors.positive}>{username}</span>
510
+ </>
511
+ ) : null}
512
+ </text>
513
+ {unreadMentionCount > 0 ? (
514
+ <text fg={colors.positive} attributes={TextAttributes.BOLD}>{` [${unreadMentionCount}]`}</text>
515
+ ) : null}
516
+ </box>
460
517
  )}
461
518
  </box>
462
519
  );
@@ -488,6 +545,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
488
545
 
489
546
  setup(ctx) {
490
547
  chatController.attachPersistence(ctx.persistence, ctx.resume);
548
+ chatController.setToastNotifier(ctx.showToast);
491
549
 
492
550
  ctx.registerPane({
493
551
  id: "chat",
@@ -609,7 +667,8 @@ export const gloomberbCloudPlugin: GloomPlugin = {
609
667
  return;
610
668
  }
611
669
  await apiClient.signOut();
612
- chatController.reset(true);
670
+ await chatController.refreshSession();
671
+ await chatController.refreshMessages();
613
672
  ctx.showToast("Logged out.", { type: "info" });
614
673
  },
615
674
  hidden: () => !apiClient.getSessionToken(),
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act } from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
4
5
  import {
5
6
  AppContext,
6
7
  createInitialState,
@@ -23,6 +24,7 @@ import {
23
24
  const TEST_PANE_ID = "comparison-chart:test";
24
25
 
25
26
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
27
+ let sharedCoordinator: MarketDataCoordinator | null = null;
26
28
 
27
29
  function makeTicker(symbol: string, currency: string): TickerRecord {
28
30
  return {
@@ -76,12 +78,19 @@ function createProvider(historyBySymbol: Record<string, number[]>, currencyBySym
76
78
  search: async () => [],
77
79
  getNews: async () => [],
78
80
  getArticleSummary: async () => null,
81
+ getChartResolutionCapabilities: async () => ["5m", "15m", "1h", "1d", "1wk", "1mo"],
79
82
  getPriceHistory: async (symbol) => (
80
83
  historyBySymbol[symbol] ?? []
81
84
  ).map((close, index) => ({
82
85
  date: new Date(2024, 0, index + 2),
83
86
  close,
84
87
  })),
88
+ getPriceHistoryForResolution: async (symbol) => (
89
+ historyBySymbol[symbol] ?? []
90
+ ).map((close, index) => ({
91
+ date: new Date(2024, 0, index + 2),
92
+ close,
93
+ })),
85
94
  };
86
95
  }
87
96
 
@@ -154,6 +163,8 @@ afterEach(() => {
154
163
  testSetup.renderer.destroy();
155
164
  testSetup = undefined;
156
165
  }
166
+ sharedCoordinator = null;
167
+ setSharedMarketDataCoordinator(null);
157
168
  setSharedRegistryForTests(undefined);
158
169
  setSharedDataProviderForTests(undefined);
159
170
  });
@@ -178,6 +189,8 @@ describe("comparisonChartPlugin", () => {
178
189
  title: "AAPL · MSFT · NVDA",
179
190
  settings: {
180
191
  axisMode: "percent",
192
+ rangePreset: "1Y",
193
+ chartResolution: "1d",
181
194
  symbols: ["AAPL", "MSFT", "NVDA"],
182
195
  symbolsText: "AAPL, MSFT, NVDA",
183
196
  },
@@ -190,19 +203,24 @@ describe("comparisonChartPlugin", () => {
190
203
  symbols: ["AAPL", "MSFT"],
191
204
  })).toEqual({
192
205
  axisMode: "percent",
206
+ rangePreset: "1Y",
207
+ chartResolution: "1d",
193
208
  symbols: ["AAPL", "MSFT"],
194
209
  symbolsText: "AAPL, MSFT",
195
210
  });
196
211
  });
197
212
 
198
213
  test("renders one shared overlay chart with the mixed-currency warning", async () => {
199
- setSharedDataProviderForTests(createProvider({
214
+ const provider = createProvider({
200
215
  AAPL: [100, 102, 104, 106],
201
216
  "7203": [2000, 2020, 2050, 2100],
202
217
  }, {
203
218
  AAPL: "USD",
204
219
  "7203": "JPY",
205
- }));
220
+ });
221
+ setSharedDataProviderForTests(provider);
222
+ sharedCoordinator = new MarketDataCoordinator(provider);
223
+ setSharedMarketDataCoordinator(sharedCoordinator);
206
224
  setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
207
225
 
208
226
  testSetup = await testRender(
@@ -224,14 +242,54 @@ describe("comparisonChartPlugin", () => {
224
242
 
225
243
  const frame = testSetup.captureCharFrame();
226
244
  expect(frame).toContain("Mixed currencies detected; showing percent change.");
227
- expect(frame).toContain("1:1W");
245
+ expect(frame).toContain("1:1D");
246
+ expect(frame).toContain("2:1W");
247
+ expect(frame).toContain("1D");
248
+ expect(frame).toContain("view:");
228
249
  expect(frame).toContain("arrows legend");
229
250
  expect(frame).not.toContain("side by side");
230
251
  });
231
252
 
253
+ test("keeps controls visible when comparison history is empty", async () => {
254
+ const provider = createProvider({
255
+ AAPL: [],
256
+ MSFT: [],
257
+ }, {
258
+ AAPL: "USD",
259
+ MSFT: "USD",
260
+ });
261
+ setSharedDataProviderForTests(provider);
262
+ sharedCoordinator = new MarketDataCoordinator(provider);
263
+ setSharedMarketDataCoordinator(sharedCoordinator);
264
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
265
+
266
+ testSetup = await testRender(
267
+ createComparisonHarness({
268
+ axisMode: "price",
269
+ symbols: ["AAPL", "MSFT"],
270
+ symbolsText: "AAPL, MSFT",
271
+ }, [
272
+ makeTicker("AAPL", "USD"),
273
+ makeTicker("MSFT", "USD"),
274
+ ], [
275
+ ["AAPL", makeFinancials("AAPL", "USD", [])],
276
+ ["MSFT", makeFinancials("MSFT", "USD", [])],
277
+ ]),
278
+ { width: 120, height: 20 },
279
+ );
280
+
281
+ await flushFrames();
282
+
283
+ const frame = testSetup.captureCharFrame();
284
+ expect(frame).toContain("No chart data yet.");
285
+ expect(frame).toContain("1:1D");
286
+ expect(frame).toContain("2:1W");
287
+ expect(frame).toContain("AUTO");
288
+ });
289
+
232
290
  test("moves legend selection with the keyboard and opens the selected ticker on Enter", async () => {
233
291
  const spy = { selected: [] as string[], focused: [] as string[] };
234
- setSharedDataProviderForTests(createProvider({
292
+ const provider = createProvider({
235
293
  AAPL: [100, 102, 104],
236
294
  MSFT: [200, 202, 204],
237
295
  NVDA: [300, 305, 310],
@@ -239,7 +297,10 @@ describe("comparisonChartPlugin", () => {
239
297
  AAPL: "USD",
240
298
  MSFT: "USD",
241
299
  NVDA: "USD",
242
- }));
300
+ });
301
+ setSharedDataProviderForTests(provider);
302
+ sharedCoordinator = new MarketDataCoordinator(provider);
303
+ setSharedMarketDataCoordinator(sharedCoordinator);
243
304
  setSharedRegistryForTests(createRegistrySpy(spy));
244
305
 
245
306
  testSetup = await testRender(
@@ -1,7 +1,12 @@
1
1
  import { useCallback, useMemo } from "react";
2
2
  import { EmptyState } from "../../components";
3
3
  import { ComparisonStockChart } from "../../components/chart/comparison-stock-chart";
4
- import type { ChartAxisMode } from "../../components/chart/chart-types";
4
+ import type { ChartAxisMode, TimeRange } from "../../components/chart/chart-types";
5
+ import {
6
+ DEFAULT_COMPARISON_CHART_RANGE_PRESET,
7
+ DEFAULT_COMPARISON_CHART_RESOLUTION,
8
+ normalizeChartResolution,
9
+ } from "../../components/chart/chart-resolution";
5
10
  import { getSharedRegistry } from "../../plugins/registry";
6
11
  import { useAppState } from "../../state/app-context";
7
12
  import { colors } from "../../theme/colors";
@@ -13,6 +18,8 @@ export const COMPARISON_CHART_TEMPLATE_ID = "comparison-chart-pane";
13
18
 
14
19
  interface ComparisonChartPaneSettings {
15
20
  axisMode: ChartAxisMode;
21
+ rangePreset: TimeRange;
22
+ chartResolution: ReturnType<typeof normalizeChartResolution>;
16
23
  symbols: string[];
17
24
  symbolsText: string;
18
25
  }
@@ -46,6 +53,17 @@ export function getComparisonChartPaneSettings(settings: Record<string, unknown>
46
53
 
47
54
  return {
48
55
  axisMode: isChartAxisMode(settings?.axisMode) ? settings.axisMode : "price",
56
+ rangePreset: settings?.rangePreset === "1D"
57
+ || settings?.rangePreset === "1W"
58
+ || settings?.rangePreset === "1M"
59
+ || settings?.rangePreset === "3M"
60
+ || settings?.rangePreset === "6M"
61
+ || settings?.rangePreset === "1Y"
62
+ || settings?.rangePreset === "5Y"
63
+ || settings?.rangePreset === "ALL"
64
+ ? settings.rangePreset
65
+ : DEFAULT_COMPARISON_CHART_RANGE_PRESET,
66
+ chartResolution: normalizeChartResolution(settings?.chartResolution, DEFAULT_COMPARISON_CHART_RESOLUTION),
49
67
  symbols,
50
68
  symbolsText: storedText.trim().length > 0 ? storedText : formatTickerListInput(symbols),
51
69
  };
@@ -159,6 +177,8 @@ export const comparisonChartPlugin: GloomPlugin = {
159
177
  placement: "floating",
160
178
  settings: {
161
179
  axisMode: "percent",
180
+ rangePreset: DEFAULT_COMPARISON_CHART_RANGE_PRESET,
181
+ chartResolution: DEFAULT_COMPARISON_CHART_RESOLUTION,
162
182
  symbols,
163
183
  symbolsText: formatTickerListInput(symbols),
164
184
  },