gloomberb 0.4.1 β†’ 0.5.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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -17,6 +17,7 @@ export interface Theme {
17
17
  positive: string;
18
18
  negative: string;
19
19
  neutral: string;
20
+ warning: string;
20
21
 
21
22
  header: string;
22
23
  headerText: string;
@@ -79,6 +80,12 @@ function normalizeTheme(theme: Theme): Theme {
79
80
  highestMinimumContrast(bodySurfaces, [textDim, text, "#c0c7d1"]),
80
81
  SUBTLE_TEXT_MIN,
81
82
  );
83
+ const warning = blendForContrastOnSurfaces(
84
+ theme.warning,
85
+ bodySurfaces,
86
+ blendHex(theme.warning, theme.textBright, 0.5),
87
+ SUBTLE_TEXT_MIN,
88
+ );
82
89
  const selected = blendForContrastOnSurfaces(
83
90
  theme.selected,
84
91
  bodySurfaces,
@@ -106,6 +113,7 @@ function normalizeTheme(theme: Theme): Theme {
106
113
  positive,
107
114
  negative,
108
115
  neutral,
116
+ warning,
109
117
  headerText,
110
118
  selected,
111
119
  selectedText,
@@ -127,6 +135,7 @@ const rawThemes: Record<string, Theme> = {
127
135
  positive: "#00cc66",
128
136
  negative: "#ff3333",
129
137
  neutral: "#888888",
138
+ warning: "#ffaa00",
130
139
  header: "#0044aa",
131
140
  headerText: "#ffffff",
132
141
  selected: "#1a3a5c",
@@ -149,6 +158,7 @@ const rawThemes: Record<string, Theme> = {
149
158
  positive: "#00ff66",
150
159
  negative: "#ff4444",
151
160
  neutral: "#666666",
161
+ warning: "#cccc00",
152
162
  header: "#003300",
153
163
  headerText: "#00ff00",
154
164
  selected: "#003300",
@@ -171,6 +181,7 @@ const rawThemes: Record<string, Theme> = {
171
181
  positive: "#00ff88",
172
182
  negative: "#ff4444",
173
183
  neutral: "#666666",
184
+ warning: "#ddcc00",
174
185
  header: "#002222",
175
186
  headerText: "#00ffff",
176
187
  selected: "#002a2a",
@@ -193,6 +204,7 @@ const rawThemes: Record<string, Theme> = {
193
204
  positive: "#44cc44",
194
205
  negative: "#ff6666",
195
206
  neutral: "#666666",
207
+ warning: "#ddaa00",
196
208
  header: "#220000",
197
209
  headerText: "#ff3333",
198
210
  selected: "#2a0000",
@@ -215,6 +227,7 @@ const rawThemes: Record<string, Theme> = {
215
227
  positive: "#00cc66",
216
228
  negative: "#ff4444",
217
229
  neutral: "#666666",
230
+ warning: "#ddaa00",
218
231
  header: "#000033",
219
232
  headerText: "#4488ff",
220
233
  selected: "#001144",
@@ -237,6 +250,7 @@ const rawThemes: Record<string, Theme> = {
237
250
  positive: "#44dd66",
238
251
  negative: "#ff4444",
239
252
  neutral: "#666666",
253
+ warning: "#ddaa00",
240
254
  header: "#1a0028",
241
255
  headerText: "#bb55ff",
242
256
  selected: "#220033",
@@ -259,6 +273,7 @@ const rawThemes: Record<string, Theme> = {
259
273
  positive: "#44dd66",
260
274
  negative: "#ff5555",
261
275
  neutral: "#666666",
276
+ warning: "#ddaa00",
262
277
  header: "#22000f",
263
278
  headerText: "#ff44aa",
264
279
  selected: "#2a0018",
@@ -281,6 +296,7 @@ const rawThemes: Record<string, Theme> = {
281
296
  positive: "#00cc66",
282
297
  negative: "#ff3333",
283
298
  neutral: "#888888",
299
+ warning: "#ddaa00",
284
300
  header: "#1a1a1a",
285
301
  headerText: "#ffffff",
286
302
  selected: "#222222",
@@ -303,6 +319,7 @@ const rawThemes: Record<string, Theme> = {
303
319
  positive: "#9ece6a",
304
320
  negative: "#f7768e",
305
321
  neutral: "#565f89",
322
+ warning: "#e0af68",
306
323
  header: "#24283b",
307
324
  headerText: "#7aa2f7",
308
325
  selected: "#283457",
@@ -325,6 +342,7 @@ const rawThemes: Record<string, Theme> = {
325
342
  positive: "#859900",
326
343
  negative: "#dc322f",
327
344
  neutral: "#657b83",
345
+ warning: "#b58900",
328
346
  header: "#073642",
329
347
  headerText: "#268bd2",
330
348
  selected: "#0a4a5c",
@@ -347,6 +365,7 @@ const rawThemes: Record<string, Theme> = {
347
365
  positive: "#50fa7b",
348
366
  negative: "#ff5555",
349
367
  neutral: "#6272a4",
368
+ warning: "#f1fa8c",
350
369
  header: "#44475a",
351
370
  headerText: "#bd93f9",
352
371
  selected: "#44475a",
@@ -369,6 +388,7 @@ const rawThemes: Record<string, Theme> = {
369
388
  positive: "#a3be8c",
370
389
  negative: "#bf616a",
371
390
  neutral: "#4c566a",
391
+ warning: "#ebcb8b",
372
392
  header: "#3b4252",
373
393
  headerText: "#88c0d0",
374
394
  selected: "#4c566a",
@@ -391,6 +411,7 @@ const rawThemes: Record<string, Theme> = {
391
411
  positive: "#a6e22e",
392
412
  negative: "#f92672",
393
413
  neutral: "#75715e",
414
+ warning: "#e6db74",
394
415
  header: "#49483e",
395
416
  headerText: "#e6db74",
396
417
  selected: "#49483e",
@@ -413,6 +434,7 @@ const rawThemes: Record<string, Theme> = {
413
434
  positive: "#a6e3a1",
414
435
  negative: "#f38ba8",
415
436
  neutral: "#6c7086",
437
+ warning: "#f9e2af",
416
438
  header: "#313244",
417
439
  headerText: "#cba6f7",
418
440
  selected: "#3e3f56",
@@ -435,6 +457,7 @@ const rawThemes: Record<string, Theme> = {
435
457
  positive: "#b8bb26",
436
458
  negative: "#fb4934",
437
459
  neutral: "#928374",
460
+ warning: "#fabd2f",
438
461
  header: "#3c3836",
439
462
  headerText: "#fabd2f",
440
463
  selected: "#3c3836",
@@ -457,6 +480,7 @@ const rawThemes: Record<string, Theme> = {
457
480
  positive: "#31748f",
458
481
  negative: "#eb6f92",
459
482
  neutral: "#6e6a86",
483
+ warning: "#f6c177",
460
484
  header: "#26233a",
461
485
  headerText: "#c4a7e7",
462
486
  selected: "#332f4a",
@@ -479,6 +503,7 @@ const rawThemes: Record<string, Theme> = {
479
503
  positive: "#00cc66",
480
504
  negative: "#ff3333",
481
505
  neutral: "#6688bb",
506
+ warning: "#ff9900",
482
507
  header: "#001144",
483
508
  headerText: "#ff6600",
484
509
  selected: "#002255",
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createPaneInstance,
4
+ findPrimaryPaneInstance,
5
+ resolveFollowBindingInstance,
6
+ resolvePaneInstance,
7
+ type LayoutConfig,
8
+ type PaneInstanceConfig,
9
+ } from "./config";
10
+
11
+ function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
12
+ return {
13
+ dockRoot: null,
14
+ instances,
15
+ floating: [],
16
+ };
17
+ }
18
+
19
+ describe("findPrimaryPaneInstance", () => {
20
+ test("prefers the main non-fixed ticker pane", () => {
21
+ const layout = createLayout([
22
+ createPaneInstance("portfolio-list", {
23
+ instanceId: "portfolio-list:main",
24
+ binding: { kind: "none" },
25
+ }),
26
+ createPaneInstance("ticker-detail", {
27
+ instanceId: "ticker-detail:pinned",
28
+ binding: { kind: "fixed", symbol: "AAPL" },
29
+ }),
30
+ createPaneInstance("ticker-detail", {
31
+ instanceId: "ticker-detail:main",
32
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
33
+ }),
34
+ ]);
35
+
36
+ expect(findPrimaryPaneInstance(layout, "ticker-detail")?.instanceId).toBe("ticker-detail:main");
37
+ });
38
+
39
+ test("does not treat fixed ticker panes as the primary pane", () => {
40
+ const layout = createLayout([
41
+ createPaneInstance("ticker-detail", {
42
+ instanceId: "ticker-detail:aapl",
43
+ binding: { kind: "fixed", symbol: "AAPL" },
44
+ }),
45
+ createPaneInstance("ticker-detail", {
46
+ instanceId: "ticker-detail:msft",
47
+ binding: { kind: "fixed", symbol: "MSFT" },
48
+ }),
49
+ ]);
50
+
51
+ expect(findPrimaryPaneInstance(layout, "ticker-detail")).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("resolvePaneInstance", () => {
56
+ test("accepts either an instance id or a pane id", () => {
57
+ const layout = createLayout([
58
+ createPaneInstance("portfolio-list", {
59
+ instanceId: "portfolio-list:main",
60
+ binding: { kind: "none" },
61
+ }),
62
+ ]);
63
+
64
+ expect(resolvePaneInstance(layout, "portfolio-list:main")?.instanceId).toBe("portfolio-list:main");
65
+ expect(resolvePaneInstance(layout, "portfolio-list")?.instanceId).toBe("portfolio-list:main");
66
+ });
67
+ });
68
+
69
+ describe("resolveFollowBindingInstance", () => {
70
+ test("walks follow bindings until it finds a matching pane", () => {
71
+ const layout = createLayout([
72
+ createPaneInstance("portfolio-list", {
73
+ instanceId: "portfolio-list:main",
74
+ binding: { kind: "none" },
75
+ }),
76
+ createPaneInstance("ticker-detail", {
77
+ instanceId: "ticker-detail:main",
78
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
79
+ }),
80
+ createPaneInstance("quote-monitor", {
81
+ instanceId: "quote-monitor:main",
82
+ binding: { kind: "follow", sourceInstanceId: "ticker-detail:main" },
83
+ }),
84
+ ]);
85
+
86
+ expect(
87
+ resolveFollowBindingInstance(layout, "quote-monitor:main", (instance) => instance.paneId === "portfolio-list")?.instanceId,
88
+ ).toBe("portfolio-list:main");
89
+ });
90
+
91
+ test("stops on follow cycles", () => {
92
+ const layout = createLayout([
93
+ createPaneInstance("ticker-detail", {
94
+ instanceId: "ticker-detail:first",
95
+ binding: { kind: "follow", sourceInstanceId: "quote-monitor:first" },
96
+ }),
97
+ createPaneInstance("quote-monitor", {
98
+ instanceId: "quote-monitor:first",
99
+ binding: { kind: "follow", sourceInstanceId: "ticker-detail:first" },
100
+ }),
101
+ ]);
102
+
103
+ expect(
104
+ resolveFollowBindingInstance(layout, "ticker-detail:first", (instance) => instance.paneId === "portfolio-list"),
105
+ ).toBeUndefined();
106
+ });
107
+ });
@@ -179,7 +179,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
179
179
  hideTabs: false,
180
180
  lockedTabId: "overview",
181
181
  chartRangePreset: "5Y",
182
- chartResolution: "1wk",
182
+ chartResolution: "auto",
183
183
  },
184
184
  binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
185
185
  },
@@ -317,6 +317,35 @@ export function isFixedTickerPane(instance: PaneInstanceConfig): boolean {
317
317
  return isTickerPaneInstance(instance) && instance.binding?.kind === "fixed";
318
318
  }
319
319
 
320
+ export function findPrimaryPaneInstance(layout: LayoutConfig, paneId: string): PaneInstanceConfig | undefined {
321
+ const instances = layout.instances.filter((instance) => instance.paneId === paneId);
322
+ if (instances.length === 0) return undefined;
323
+ if (!isTickerPaneId(paneId)) return instances[0];
324
+ return instances.find((instance) => instance.instanceId === `${paneId}:main` && instance.binding?.kind !== "fixed")
325
+ ?? instances.find((instance) => instance.binding?.kind !== "fixed");
326
+ }
327
+
328
+ export function resolvePaneInstance(layout: LayoutConfig, paneIdOrInstanceId: string): PaneInstanceConfig | undefined {
329
+ return findPaneInstance(layout, paneIdOrInstanceId) ?? findPrimaryPaneInstance(layout, paneIdOrInstanceId);
330
+ }
331
+
332
+ export function resolveFollowBindingInstance(
333
+ layout: LayoutConfig,
334
+ paneIdOrInstanceId: string | null | undefined,
335
+ matcher: (instance: PaneInstanceConfig) => boolean,
336
+ seen = new Set<string>(),
337
+ ): PaneInstanceConfig | undefined {
338
+ if (!paneIdOrInstanceId) return undefined;
339
+ const instance = resolvePaneInstance(layout, paneIdOrInstanceId);
340
+ if (!instance || seen.has(instance.instanceId)) return undefined;
341
+ seen.add(instance.instanceId);
342
+ if (matcher(instance)) return instance;
343
+ if (instance.binding?.kind === "follow") {
344
+ return resolveFollowBindingInstance(layout, instance.binding.sourceInstanceId, matcher, seen);
345
+ }
346
+ return undefined;
347
+ }
348
+
320
349
  export function createPaneInstance(
321
350
  paneId: string,
322
351
  options: Partial<PaneInstanceConfig> = {},
@@ -26,6 +26,18 @@ export interface SecFilingItem {
26
26
  primaryDocumentUrl?: string;
27
27
  }
28
28
 
29
+ export interface EarningsEvent {
30
+ symbol: string;
31
+ name: string;
32
+ earningsDate: Date;
33
+ epsEstimate: number | null;
34
+ epsActual: number | null;
35
+ revenueEstimate: number | null;
36
+ revenueActual: number | null;
37
+ surprise: number | null;
38
+ timing: "BMO" | "AMC" | "TNS" | "";
39
+ }
40
+
29
41
  export interface MarketDataRequestContext {
30
42
  brokerId?: string;
31
43
  brokerInstanceId?: string;
@@ -68,6 +80,7 @@ export interface DataProvider {
68
80
  search(query: string, context?: SearchRequestContext): Promise<InstrumentSearchResult[]>;
69
81
  getNews(ticker: string, count?: number, exchange?: string, context?: MarketDataRequestContext): Promise<NewsItem[]>;
70
82
  getSecFilings?(ticker: string, count?: number, exchange?: string, context?: MarketDataRequestContext): Promise<SecFilingItem[]>;
83
+ getEarningsCalendar?(symbols: string[], context?: MarketDataRequestContext): Promise<EarningsEvent[]>;
71
84
  getSecFilingContent?(filing: SecFilingItem): Promise<string | null>;
72
85
  /** Fetch article summary/description by URL (lazy-loaded on selection) */
73
86
  getArticleSummary(url: string): Promise<string | null>;
@@ -169,6 +169,19 @@ export interface WizardStep {
169
169
  body?: string[];
170
170
  }
171
171
 
172
+ export interface CommandShortcutArgContext {
173
+ activeTicker: string | null;
174
+ }
175
+
176
+ export interface CommandShortcutArgDef {
177
+ placeholder?: string;
178
+ kind?: "text" | "ticker" | "ticker-list";
179
+ parse?: (
180
+ arg: string,
181
+ context: CommandShortcutArgContext,
182
+ ) => Record<string, string>;
183
+ }
184
+
172
185
  export interface CliHelpColumn {
173
186
  header: string;
174
187
  align?: "left" | "right" | "center";
@@ -243,6 +256,7 @@ export interface CommandDef {
243
256
  label: string;
244
257
  keywords: string[];
245
258
  shortcut?: string;
259
+ shortcutArg?: CommandShortcutArgDef;
246
260
  execute: (values?: Record<string, string>) => void | Promise<void>;
247
261
  category: "navigation" | "data" | "portfolio" | "config";
248
262
  description?: string;
@@ -355,6 +369,25 @@ export interface PluginPaneSettingsState {
355
369
  delete(paneId: string, key: string): Promise<void>;
356
370
  }
357
371
 
372
+ export type AppNotificationType = "info" | "success" | "error";
373
+ export type AppDesktopNotificationMode = "never" | "when-inactive" | "always";
374
+
375
+ export interface AppNotificationRequest {
376
+ title?: string;
377
+ body: string;
378
+ subtitle?: string;
379
+ duration?: number;
380
+ type?: AppNotificationType;
381
+ toast?: boolean;
382
+ desktop?: AppDesktopNotificationMode;
383
+ persistent?: boolean;
384
+ sound?: string;
385
+ action?: {
386
+ label: string;
387
+ onClick: () => void;
388
+ };
389
+ }
390
+
358
391
  export interface GloomPluginContext {
359
392
  registerPane(pane: PaneDef): void;
360
393
  registerPaneTemplate(template: PaneTemplateDef): void;
@@ -392,6 +425,7 @@ export interface GloomPluginContext {
392
425
  hidePane(paneId: string): void;
393
426
  focusPane(paneId: string): void;
394
427
  pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
428
+ navigateTicker(symbol: string): void;
395
429
  openPaneSettings(paneId?: string): void;
396
430
 
397
431
  on<K extends keyof PluginEvents>(event: K, handler: (payload: PluginEvents[K]) => void): () => void;
@@ -399,7 +433,7 @@ export interface GloomPluginContext {
399
433
 
400
434
  showWidget(widgetId: string): void;
401
435
  hideWidget(widgetId: string): void;
402
- showToast(message: string, options?: { duration?: number; type?: "info" | "success" | "error" }): void;
436
+ notify(notification: AppNotificationRequest): void;
403
437
  }
404
438
 
405
439
  export interface GloomPlugin {
@@ -3,6 +3,7 @@ import type { AuthUser } from "./api-client";
3
3
  import { apiClient } from "./api-client";
4
4
 
5
5
  const originalFetch = globalThis.fetch;
6
+ const originalWebSocket = globalThis.WebSocket;
6
7
 
7
8
  const verifiedUser: AuthUser = {
8
9
  id: "user-1",
@@ -33,7 +34,9 @@ function createResponse(body: unknown, options: { status?: number; cookies?: str
33
34
  }
34
35
 
35
36
  afterEach(() => {
37
+ apiClient.dispose();
36
38
  globalThis.fetch = originalFetch;
39
+ globalThis.WebSocket = originalWebSocket;
37
40
  apiClient.setSessionToken(null);
38
41
  apiClient.setWebSocketToken(null);
39
42
  });
@@ -83,6 +86,93 @@ describe("apiClient auth cookies", () => {
83
86
  "__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
84
87
  ]);
85
88
  });
89
+
90
+ test("keeps cached identity when session refresh is rejected without a hard account-missing response", async () => {
91
+ apiClient.setSessionToken("persisted-token.value");
92
+ apiClient.restoreCachedUser(verifiedUser);
93
+
94
+ globalThis.fetch = (async () => createResponse({ message: "Unauthorized" }, { status: 401 })) as typeof fetch;
95
+
96
+ await expect(apiClient.getSession()).rejects.toThrow("Unauthorized");
97
+ expect(apiClient.getSessionToken()).toBe("persisted-token.value");
98
+ expect(apiClient.getCurrentUser()).toMatchObject({
99
+ id: verifiedUser.id,
100
+ username: verifiedUser.username,
101
+ emailVerified: true,
102
+ });
103
+ });
104
+
105
+ test("clears cached identity when session refresh says the account no longer exists", async () => {
106
+ apiClient.setSessionToken("persisted-token.value");
107
+ apiClient.setWebSocketToken("ws-token");
108
+ apiClient.restoreCachedUser(verifiedUser);
109
+
110
+ globalThis.fetch = (async () => createResponse({ code: "USER_NOT_FOUND" }, { status: 403 })) as typeof fetch;
111
+
112
+ await expect(apiClient.getSession()).resolves.toBeNull();
113
+ expect(apiClient.getSessionToken()).toBeNull();
114
+ expect(apiClient.getWebSocketToken()).toBeNull();
115
+ expect(apiClient.getCurrentUser()).toBeNull();
116
+ });
117
+
118
+ test("clears local session on explicit sign out even if the server request fails", async () => {
119
+ apiClient.setSessionToken("persisted-token.value");
120
+ apiClient.setWebSocketToken("ws-token");
121
+ apiClient.restoreCachedUser(verifiedUser);
122
+
123
+ globalThis.fetch = (async () => createResponse({ message: "server unavailable" }, { status: 503 })) as typeof fetch;
124
+
125
+ await expect(apiClient.signOut()).rejects.toThrow("server unavailable");
126
+ expect(apiClient.getSessionToken()).toBeNull();
127
+ expect(apiClient.getWebSocketToken()).toBeNull();
128
+ expect(apiClient.getCurrentUser()).toBeNull();
129
+ });
130
+
131
+ test("drops a stale websocket token after socket close so reconnect can use the session token", () => {
132
+ const sockets: Array<{
133
+ url: string;
134
+ readyState: number;
135
+ onclose: ((event: unknown) => void) | null;
136
+ close: () => void;
137
+ }> = [];
138
+
139
+ class FakeWebSocket {
140
+ static OPEN = 1;
141
+ readyState = 0;
142
+ onopen: ((event: unknown) => void) | null = null;
143
+ onmessage: ((event: { data: string }) => void) | null = null;
144
+ onclose: ((event: unknown) => void) | null = null;
145
+ onerror: ((event: unknown) => void) | null = null;
146
+
147
+ constructor(readonly url: string) {
148
+ sockets.push(this);
149
+ }
150
+
151
+ send(): void {}
152
+
153
+ close(): void {
154
+ this.readyState = 3;
155
+ this.onclose?.({ code: 1000, reason: "closed" });
156
+ }
157
+ }
158
+
159
+ globalThis.WebSocket = FakeWebSocket as any;
160
+ apiClient.setSessionToken("session-token");
161
+ apiClient.setWebSocketToken("stale-ws-token");
162
+ apiClient.restoreCachedUser(verifiedUser);
163
+
164
+ const unsubscribe = apiClient.subscribeQuotes([{ symbol: "AAPL" }], () => {});
165
+
166
+ expect(sockets).toHaveLength(1);
167
+ expect(sockets[0]!.url).toContain("token=stale-ws-token");
168
+
169
+ sockets[0]!.onclose?.({ code: 1008, reason: "Unauthorized" });
170
+
171
+ expect(apiClient.getWebSocketToken()).toBeNull();
172
+ expect(apiClient.getSessionToken()).toBe("session-token");
173
+
174
+ unsubscribe();
175
+ });
86
176
  });
87
177
 
88
178
  describe("apiClient chat timestamps", () => {
@@ -6,6 +6,11 @@ import { normalizeTimestamp } from "./timestamp";
6
6
  const DEFAULT_API_URL = "https://api.gloom.sh";
7
7
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
8
8
  const cloudApiLog = debugLog.createLogger("cloud-api");
9
+ const HARD_SESSION_INVALID_PATTERNS = [
10
+ /\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
11
+ /\b(user|account)\b.*\bdoes(?:\s+not|n't)\s+exist\b/i,
12
+ /\b(no|unknown|missing)\s+(user|account)\b/i,
13
+ ];
9
14
 
10
15
  export interface ChatMessage {
11
16
  id: string;
@@ -131,6 +136,22 @@ class ApiRequestError extends Error {
131
136
  }
132
137
  }
133
138
 
139
+ function parseApiErrorMessage(body: string): string {
140
+ try {
141
+ const parsed = JSON.parse(body) as Record<string, unknown>;
142
+ const parts = [parsed.message, parsed.error, parsed.code, parsed.reason]
143
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
144
+ return parts.join(" ") || body;
145
+ } catch {
146
+ return body;
147
+ }
148
+ }
149
+
150
+ function isHardSessionInvalidMessage(message: string): boolean {
151
+ const normalized = message.replace(/[_-]+/g, " ");
152
+ return HARD_SESSION_INVALID_PATTERNS.some((pattern) => pattern.test(normalized));
153
+ }
154
+
134
155
  class GloomApiClient {
135
156
  private sessionToken: string | null = null;
136
157
  private sessionCookieName: SessionCookieName | null = null;
@@ -262,12 +283,7 @@ class GloomApiClient {
262
283
 
263
284
  if (!res.ok) {
264
285
  const body = await res.text();
265
- let msg: string;
266
- try {
267
- msg = JSON.parse(body).message ?? body;
268
- } catch {
269
- msg = body;
270
- }
286
+ const msg = parseApiErrorMessage(body);
271
287
  throw new ApiRequestError(msg, res.status);
272
288
  }
273
289
 
@@ -300,10 +316,11 @@ class GloomApiClient {
300
316
 
301
317
  const socketToken = this.getSocketAuthToken();
302
318
  if (!socketToken) return;
319
+ const usingWebSocketToken = !!this.websocketToken;
303
320
  const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
304
321
  cloudApiLog.info("open websocket", {
305
322
  hasToken: !!socketToken,
306
- tokenSource: this.websocketToken ? "websocket" : "session",
323
+ tokenSource: usingWebSocketToken ? "websocket" : "session",
307
324
  quoteTargets: this.quoteTargets.size,
308
325
  channelTargets: this.channelListeners.size,
309
326
  });
@@ -321,11 +338,24 @@ class GloomApiClient {
321
338
  void this.handleSocketMessage(String(event.data));
322
339
  };
323
340
 
324
- ws.onclose = () => {
341
+ ws.onclose = (event) => {
342
+ const activeSocket = this.ws === ws;
325
343
  if (this.ws === ws) {
326
344
  this.ws = null;
327
345
  }
328
- cloudApiLog.warn("websocket closed", { quoteTargets: this.quoteTargets.size, channelTargets: this.channelListeners.size });
346
+ const closeEvent = event as CloseEvent | undefined;
347
+ cloudApiLog.warn("websocket closed", {
348
+ quoteTargets: this.quoteTargets.size,
349
+ channelTargets: this.channelListeners.size,
350
+ code: closeEvent?.code,
351
+ reason: closeEvent?.reason,
352
+ tokenSource: usingWebSocketToken ? "websocket" : "session",
353
+ });
354
+ if (activeSocket && usingWebSocketToken && this.sessionToken) {
355
+ this.websocketToken = null;
356
+ this.reconnectDelayMs = 1000;
357
+ cloudApiLog.warn("cleared websocket token after socket close; falling back to session token");
358
+ }
329
359
  if (!this.shouldKeepSocketOpen()) return;
330
360
  this.scheduleReconnect();
331
361
  };
@@ -410,6 +440,14 @@ class GloomApiClient {
410
440
 
411
441
  if (parsed?.type === "auth.unverified") {
412
442
  cloudApiLog.warn("websocket marked unverified");
443
+ if (this.websocketToken && this.sessionToken) {
444
+ this.websocketToken = null;
445
+ this.reconnectDelayMs = 1000;
446
+ cloudApiLog.warn("cleared websocket token after auth rejection; falling back to session token");
447
+ this.teardownSocket();
448
+ this.scheduleReconnect();
449
+ return;
450
+ }
413
451
  if (this.currentUser) {
414
452
  this.currentUser = { ...this.currentUser, emailVerified: false };
415
453
  }
@@ -464,9 +502,11 @@ class GloomApiClient {
464
502
  }
465
503
 
466
504
  async signOut(): Promise<void> {
467
- await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
468
- this.sessionToken = null;
469
- this.setCurrentUser(null);
505
+ try {
506
+ await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
507
+ } finally {
508
+ this.setSessionToken(null);
509
+ }
470
510
  }
471
511
 
472
512
  async getSession(): Promise<AuthUser | null> {
@@ -478,11 +518,11 @@ class GloomApiClient {
478
518
  this.setCurrentUser(user);
479
519
  return user;
480
520
  } catch (error) {
481
- if (!(error instanceof ApiRequestError) || (error.status !== 401 && error.status !== 403)) {
482
- throw error;
521
+ if (error instanceof ApiRequestError && isHardSessionInvalidMessage(error.message)) {
522
+ this.setSessionToken(null);
523
+ return null;
483
524
  }
484
- this.setCurrentUser(null);
485
- return null;
525
+ throw error;
486
526
  }
487
527
  }
488
528
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { formatTimeAgo } from "./format";
2
+ import { displayWidth, formatTimeAgo, padTo } from "./format";
3
3
 
4
4
  describe("formatTimeAgo", () => {
5
5
  test("handles UTC ISO timestamps with explicit offsets", () => {
@@ -12,3 +12,13 @@ describe("formatTimeAgo", () => {
12
12
  expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
13
13
  });
14
14
  });
15
+
16
+ describe("padTo", () => {
17
+ test("pads and truncates by display width instead of UTF-16 length", () => {
18
+ expect(displayWidth("πŸ‡ΊπŸ‡Έ")).toBe(2);
19
+ expect(padTo("πŸ‡ΊπŸ‡Έ", 2)).toBe("πŸ‡ΊπŸ‡Έ");
20
+ expect(padTo("πŸ‡ΊπŸ‡Έ", 3)).toBe("πŸ‡ΊπŸ‡Έ ");
21
+ expect(padTo("πŸ‡ΊπŸ‡Έ", 1)).toBe(" ");
22
+ expect(padTo("πŸ‡ΊπŸ‡Έ CPI", 6)).toBe("πŸ‡ΊπŸ‡Έ CPI");
23
+ });
24
+ });