gloomberb 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -7,8 +7,8 @@ import {
7
7
  type DataTableColumn,
8
8
  type DataTableKeyEvent,
9
9
  } from "../../../components";
10
- import type { AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
11
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../../types/plugin";
10
+ import type { AnalystRatingRecord, AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
11
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
12
12
  import { usePaneInstance, usePaneTicker } from "../../../state/app-context";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
14
14
  import { formatCompact, formatCurrency, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
@@ -110,6 +110,32 @@ function ratingActionColor(action: string | undefined): string {
110
110
  return colors.textDim;
111
111
  }
112
112
 
113
+ function formatPriceTarget(value: number | undefined, currency: string): string {
114
+ if (value == null) return "-";
115
+ return formatCurrency(value, currency)
116
+ .replace(/\.00\b/, "")
117
+ .replace(/(\.\d)0\b/, "$1");
118
+ }
119
+
120
+ function formatRatingTarget(row: AnalystResearchData["ratings"][number], currency: string): string {
121
+ const current = row.currentPriceTarget;
122
+ const prior = row.priorPriceTarget;
123
+ if (current == null && prior == null) return "-";
124
+ if (current == null) return ` ${formatPriceTarget(prior, currency)}`;
125
+ if (prior == null) return ` ${formatPriceTarget(current, currency)}`;
126
+ return ` ${formatPriceTarget(prior, currency)} → ${formatPriceTarget(current, currency)}`;
127
+ }
128
+
129
+ function ratingTargetDelta(row: AnalystResearchData["ratings"][number]): number | null {
130
+ if (row.currentPriceTarget == null || row.priorPriceTarget == null) return null;
131
+ return row.currentPriceTarget - row.priorPriceTarget;
132
+ }
133
+
134
+ function ratingTargetBackground(delta: number | null): string | undefined {
135
+ if (delta == null || delta === 0) return undefined;
136
+ return blendHex(colors.bg, delta > 0 ? colors.positive : colors.negative, 0.42);
137
+ }
138
+
113
139
  function AnalystSummary({ data, width }: { data: AnalystResearchData | null; width: number }) {
114
140
  const target = data?.priceTarget;
115
141
  const upside = targetUpside(target);
@@ -170,26 +196,135 @@ function AnalystSummary({ data, width }: { data: AnalystResearchData | null; wid
170
196
  );
171
197
  }
172
198
 
173
- type RatingColumnId = "date" | "firm" | "action" | "current" | "prior";
199
+ export type RatingColumnId = "date" | "firm" | "action" | "current" | "target" | "prior";
174
200
  type RatingColumn = DataTableColumn & { id: RatingColumnId };
201
+ export type SortDirection = "asc" | "desc";
202
+
203
+ export interface RatingSortPreference {
204
+ columnId: RatingColumnId;
205
+ direction: SortDirection;
206
+ }
207
+
208
+ const DEFAULT_RATING_SORT: RatingSortPreference = {
209
+ columnId: "date",
210
+ direction: "desc",
211
+ };
212
+
213
+ const DEFAULT_RATING_SORT_DIRECTIONS: Record<RatingColumnId, SortDirection> = {
214
+ date: "desc",
215
+ firm: "asc",
216
+ action: "asc",
217
+ current: "asc",
218
+ target: "desc",
219
+ prior: "asc",
220
+ };
175
221
 
176
222
  const RATING_COLUMNS: RatingColumn[] = [
177
223
  { id: "date", label: "DATE", width: 10, align: "left" },
178
- { id: "firm", label: "FIRM", width: 24, align: "left" },
179
- { id: "action", label: "ACTION", width: 11, align: "left" },
180
- { id: "current", label: "RATING", width: 14, align: "left" },
181
- { id: "prior", label: "PRIOR", width: 14, align: "left" },
224
+ { id: "firm", label: "FIRM", width: 20, align: "left" },
225
+ { id: "action", label: "ACTION", width: 10, align: "left" },
226
+ { id: "current", label: "RATING", width: 13, align: "left" },
227
+ { id: "target", label: "TARGET", width: 13, align: "left" },
228
+ { id: "prior", label: "PRIOR", width: 13, align: "left" },
182
229
  ];
183
230
 
231
+ function normalizedText(value: string | undefined): string | null {
232
+ const trimmed = value?.trim();
233
+ return trimmed ? trimmed.toLocaleLowerCase() : null;
234
+ }
235
+
236
+ function ratingTargetSortValue(row: AnalystRatingRecord): number | null {
237
+ return row.currentPriceTarget ?? row.priorPriceTarget ?? null;
238
+ }
239
+
240
+ function ratingSortValue(row: AnalystRatingRecord, columnId: RatingColumnId): string | number | null {
241
+ switch (columnId) {
242
+ case "date":
243
+ return normalizedText(row.date);
244
+ case "firm":
245
+ return normalizedText(row.firm);
246
+ case "action":
247
+ return normalizedText(row.action);
248
+ case "current":
249
+ return normalizedText(row.current);
250
+ case "target":
251
+ return ratingTargetSortValue(row);
252
+ case "prior":
253
+ return normalizedText(row.prior);
254
+ }
255
+ }
256
+
257
+ function compareSortValues(
258
+ left: string | number | null,
259
+ right: string | number | null,
260
+ direction: SortDirection,
261
+ ): number {
262
+ if (left == null && right == null) return 0;
263
+ if (left == null) return 1;
264
+ if (right == null) return -1;
265
+
266
+ const comparison = typeof left === "string" && typeof right === "string"
267
+ ? left.localeCompare(right)
268
+ : Number(left) - Number(right);
269
+ return direction === "asc" ? comparison : -comparison;
270
+ }
271
+
272
+ export function sortRatingRows<T extends AnalystRatingRecord>(
273
+ rows: readonly T[],
274
+ preference: RatingSortPreference,
275
+ ): T[] {
276
+ return rows
277
+ .map((row, index) => ({ row, index }))
278
+ .sort((left, right) => {
279
+ const primary = compareSortValues(
280
+ ratingSortValue(left.row, preference.columnId),
281
+ ratingSortValue(right.row, preference.columnId),
282
+ preference.direction,
283
+ );
284
+ if (primary !== 0) return primary;
285
+
286
+ const dateTieBreak = preference.columnId === "date"
287
+ ? 0
288
+ : compareSortValues(
289
+ ratingSortValue(left.row, "date"),
290
+ ratingSortValue(right.row, "date"),
291
+ "desc",
292
+ );
293
+ if (dateTieBreak !== 0) return dateTieBreak;
294
+
295
+ return left.index - right.index;
296
+ })
297
+ .map((entry) => entry.row);
298
+ }
299
+
300
+ export function nextRatingSortPreference(
301
+ current: RatingSortPreference,
302
+ columnId: string,
303
+ ): RatingSortPreference {
304
+ const typedColumnId = columnId as RatingColumnId;
305
+ if (current.columnId !== typedColumnId) {
306
+ return {
307
+ columnId: typedColumnId,
308
+ direction: DEFAULT_RATING_SORT_DIRECTIONS[typedColumnId] ?? "asc",
309
+ };
310
+ }
311
+ return {
312
+ columnId: typedColumnId,
313
+ direction: current.direction === "asc" ? "desc" : "asc",
314
+ };
315
+ }
316
+
184
317
  function AnalystResearchView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
185
318
  const dataProvider = useAssetData();
186
319
  const { symbol, exchange } = useSymbolBinding();
320
+ const [sortPreference, setSortPreference] = useState<RatingSortPreference>(DEFAULT_RATING_SORT);
187
321
  const loader = useCallback((nextSymbol: string, nextExchange: string, forceRefresh: boolean) => {
188
322
  if (!dataProvider?.getAnalystResearch) throw new Error("Analyst data unavailable");
189
323
  return dataProvider.getAnalystResearch(nextSymbol, nextExchange, forceRefresh ? { cacheMode: "refresh" } : undefined);
190
324
  }, [dataProvider]);
191
325
  const { data, loading, error, reload } = useTickerRequest<AnalystResearchData>(loader, symbol, exchange);
192
- const rows = data?.ratings ?? [];
326
+ const rows = useMemo(() => sortRatingRows(data?.ratings ?? [], sortPreference), [data?.ratings, sortPreference]);
327
+ const ratingCurrency = data?.priceTarget?.currency ?? data?.currency ?? "USD";
193
328
 
194
329
  const renderCell = useCallback((
195
330
  row: AnalystResearchData["ratings"][number],
@@ -207,10 +342,20 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
207
342
  return { text: row.action ?? "-", color: selectedColor ?? ratingActionColor(row.action) };
208
343
  case "current":
209
344
  return { text: row.current ?? "-", color: selectedColor ?? colors.text };
345
+ case "target": {
346
+ const delta = ratingTargetDelta(row);
347
+ const hasTarget = row.currentPriceTarget != null || row.priorPriceTarget != null;
348
+ return {
349
+ text: formatRatingTarget(row, ratingCurrency),
350
+ color: selectedColor ?? (hasTarget ? colors.textBright : colors.textDim),
351
+ backgroundColor: rowState.selected ? undefined : ratingTargetBackground(delta),
352
+ attributes: hasTarget ? TextAttributes.BOLD : undefined,
353
+ };
354
+ }
210
355
  case "prior":
211
356
  return { text: row.prior ?? "-", color: selectedColor ?? colors.textDim };
212
357
  }
213
- }, []);
358
+ }, [ratingCurrency]);
214
359
 
215
360
  const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
216
361
  if (event.name !== "r") return false;
@@ -219,6 +364,9 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
219
364
  reload();
220
365
  return true;
221
366
  }, [reload]);
367
+ const handleHeaderClick = useCallback((columnId: string) => {
368
+ setSortPreference((current) => nextRatingSortPreference(current, columnId));
369
+ }, []);
222
370
 
223
371
  usePaneFooter("analyst-research", () => ({
224
372
  info: [
@@ -237,15 +385,14 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
237
385
  onRootKeyDown={handleKeyDown}
238
386
  columns={RATING_COLUMNS}
239
387
  items={rows}
240
- sortColumnId={null}
241
- sortDirection="desc"
242
- onHeaderClick={() => {}}
388
+ sortColumnId={sortPreference.columnId}
389
+ sortDirection={sortPreference.direction}
390
+ onHeaderClick={handleHeaderClick}
243
391
  getItemKey={(row, index) => `${row.date}:${row.firm}:${index}`}
244
392
  isSelected={() => false}
245
393
  onSelect={() => {}}
246
394
  renderCell={renderCell}
247
395
  emptyStateTitle={loading ? "Loading analyst data..." : error ?? "No analyst data"}
248
- showHorizontalScrollbar={false}
249
396
  />
250
397
  );
251
398
  }
@@ -609,22 +756,6 @@ function RelativeValuationPane({ focused, width, height }: PaneProps) {
609
756
  );
610
757
  }
611
758
 
612
- function ResearchPane({ focused, width, height }: PaneProps) {
613
- return <AnalystResearchView focused={focused} width={width} height={height} />;
614
- }
615
-
616
- function EventsPane({ focused, width, height }: PaneProps) {
617
- return <CorporateActionsView focused={focused} width={width} height={height} />;
618
- }
619
-
620
- function AnalystTab({ focused, width, height }: DetailTabProps) {
621
- return <AnalystResearchView focused={focused} width={width} height={height} />;
622
- }
623
-
624
- function EventsTab({ focused, width, height }: DetailTabProps) {
625
- return <CorporateActionsView focused={focused} width={width} height={height} />;
626
- }
627
-
628
759
  export const researchPlugin: GloomPlugin = {
629
760
  id: "research",
630
761
  name: "Research",
@@ -637,14 +768,14 @@ export const researchPlugin: GloomPlugin = {
637
768
  id: "analyst-research",
638
769
  name: "Analyst",
639
770
  order: 32,
640
- component: AnalystTab,
771
+ component: AnalystResearchView,
641
772
  isVisible: ({ ticker }) => !!ticker,
642
773
  });
643
774
  ctx.registerDetailTab({
644
775
  id: "corporate-actions",
645
776
  name: "Events",
646
777
  order: 34,
647
- component: EventsTab,
778
+ component: CorporateActionsView,
648
779
  isVisible: ({ ticker }) => !!ticker,
649
780
  });
650
781
  },
@@ -654,7 +785,7 @@ export const researchPlugin: GloomPlugin = {
654
785
  id: "analyst-research",
655
786
  name: "Analyst Research",
656
787
  icon: "A",
657
- component: ResearchPane,
788
+ component: AnalystResearchView,
658
789
  defaultPosition: "right",
659
790
  defaultMode: "floating",
660
791
  defaultFloatingSize: { width: 90, height: 28 },
@@ -663,7 +794,7 @@ export const researchPlugin: GloomPlugin = {
663
794
  id: "corporate-actions",
664
795
  name: "Corporate Actions",
665
796
  icon: "E",
666
- component: EventsPane,
797
+ component: CorporateActionsView,
667
798
  defaultPosition: "right",
668
799
  defaultMode: "floating",
669
800
  defaultFloatingSize: { width: 82, height: 24 },
@@ -0,0 +1,84 @@
1
+ import { Box, Text } from "../../ui";
2
+ import { colors } from "../../theme/colors";
3
+ import { formatCompact, formatCurrency } from "../../utils/format";
4
+ import { transactionTypeLabel, type InsiderTransaction } from "./insider/insider-data";
5
+
6
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
7
+
8
+ export function formatFilingShortDate(value: Date | string | number): string {
9
+ const date = value instanceof Date ? value : new Date(value);
10
+ if (isNaN(date.getTime())) return "—";
11
+ return `${MONTH_NAMES[date.getMonth()]} ${String(date.getDate()).padStart(2, " ")} ${date.getFullYear()}`;
12
+ }
13
+
14
+ export function formatFilingMetaDate(value: Date): string {
15
+ return value.toLocaleString("en-US", {
16
+ month: "short",
17
+ day: "numeric",
18
+ year: "numeric",
19
+ });
20
+ }
21
+
22
+ export function formatFilingFormLabel(form: string, fallback = "FORM 4"): string {
23
+ const value = form.trim();
24
+ return value ? `FORM ${value}` : fallback;
25
+ }
26
+
27
+ export function renderFilingNotice(message: string, width: number) {
28
+ const lines = wrapNoticeLines(message, width - 4);
29
+ return (
30
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
31
+ {lines.map((line, index) => (
32
+ <Box key={index} height={1}>
33
+ <Text fg={colors.textDim}>{line}</Text>
34
+ </Box>
35
+ ))}
36
+ </Box>
37
+ );
38
+ }
39
+
40
+ export function buildInsiderTransactionTitle(transaction: InsiderTransaction): string {
41
+ const type = transactionTypeLabel(transaction.transactionType);
42
+ const price = transaction.pricePerShare != null
43
+ ? ` @ ${formatCurrency(transaction.pricePerShare)}`
44
+ : "";
45
+ const value = transaction.totalValue != null
46
+ ? ` | ${formatCurrency(transaction.totalValue)}`
47
+ : "";
48
+ return `${type} ${formatCompact(transaction.shares)} shares${price}${value}`;
49
+ }
50
+
51
+ export function buildInsiderTransactionDetailBody(transaction: InsiderTransaction): string {
52
+ const lines = [
53
+ `Transaction: ${transactionTypeLabel(transaction.transactionType)}`,
54
+ `Date: ${formatFilingShortDate(transaction.filingDate)}`,
55
+ `Shares: ${formatCompact(transaction.shares)}`,
56
+ `Price/Share: ${transaction.pricePerShare != null ? formatCurrency(transaction.pricePerShare) : "—"}`,
57
+ `Total Value: ${transaction.totalValue != null ? formatCurrency(transaction.totalValue) : "—"}`,
58
+ `Shares Owned After: ${transaction.sharesOwned != null ? formatCompact(transaction.sharesOwned) : "—"}`,
59
+ ];
60
+ return lines.join("\n");
61
+ }
62
+
63
+ function wrapNoticeLines(text: string, width: number): string[] {
64
+ const maxWidth = Math.max(width, 12);
65
+ const words = text.trim().split(/\s+/);
66
+ const lines: string[] = [];
67
+ let current = "";
68
+
69
+ for (const word of words) {
70
+ if (!current) {
71
+ current = word;
72
+ continue;
73
+ }
74
+ if (current.length + 1 + word.length <= maxWidth) {
75
+ current = `${current} ${word}`;
76
+ } else {
77
+ lines.push(current);
78
+ current = word;
79
+ }
80
+ }
81
+
82
+ if (current) lines.push(current);
83
+ return lines;
84
+ }
@@ -1,6 +1,6 @@
1
- import { Box, Text } from "../../ui";
1
+ import { Text } from "../../ui";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../types/plugin";
3
+ import type { GloomPlugin } from "../../types/plugin";
4
4
  import type { SecFilingItem } from "../../types/data-provider";
5
5
  import { useResolvedEntryValue, useSecFilingContent, useSecFilingsQuery } from "../../market-data/hooks";
6
6
  import { instrumentFromTicker } from "../../market-data/request-types";
@@ -13,6 +13,10 @@ import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
13
13
  import { parseForm4Xml, transactionTypeLabel } from "./insider/insider-data";
14
14
  import { formatCompact, formatCurrency } from "../../utils/format";
15
15
  import { createTickerSurfacePaneTemplate } from "./ticker-surface";
16
+ import {
17
+ formatFilingMetaDate,
18
+ renderFilingNotice,
19
+ } from "./sec-filing-display";
16
20
 
17
21
  const SEC_FILING_LIMIT = 50;
18
22
  const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
@@ -60,48 +64,8 @@ function getFilingDisplayTitle(filing: SecFilingItem): string {
60
64
  return description ? `${formLabel} | ${description}` : formLabel;
61
65
  }
62
66
 
63
- function wrapMessageLines(text: string, width: number): string[] {
64
- const maxWidth = Math.max(width, 12);
65
- const words = text.trim().split(/\s+/);
66
- const lines: string[] = [];
67
- let current = "";
68
-
69
- for (const word of words) {
70
- if (!current) {
71
- current = word;
72
- continue;
73
- }
74
- if ((current.length + 1 + word.length) <= maxWidth) {
75
- current = `${current} ${word}`;
76
- } else {
77
- lines.push(current);
78
- current = word;
79
- }
80
- }
81
-
82
- if (current) lines.push(current);
83
- return lines;
84
- }
85
-
86
- function renderNotice(message: string, width: number) {
87
- const lines = wrapMessageLines(message, width - 4);
88
- return (
89
- <Box flexDirection="column" paddingX={1} paddingY={1}>
90
- {lines.map((line, index) => (
91
- <Box key={index} height={1}>
92
- <Text fg={colors.textDim}>{line}</Text>
93
- </Box>
94
- ))}
95
- </Box>
96
- );
97
- }
98
-
99
67
  function formatFiledAt(filing: SecFilingItem): string {
100
- return filing.filingDate.toLocaleString("en-US", {
101
- month: "short",
102
- day: "numeric",
103
- year: "numeric",
104
- });
68
+ return formatFilingMetaDate(filing.filingDate);
105
69
  }
106
70
 
107
71
  function buildDetailBody(filing: SecFilingItem): string {
@@ -142,15 +106,6 @@ function buildForm4Detail(content: string | null, filing: SecFilingItem): string
142
106
  return lines.join("\n");
143
107
  }
144
108
 
145
- function getFormCategory(form: string): "annual" | "quarterly" | "current" | "ownership" | "other" {
146
- const f = form.trim().toUpperCase();
147
- if (f === "10-K" || f === "10-K/A" || f === "20-F") return "annual";
148
- if (f === "10-Q" || f === "10-Q/A") return "quarterly";
149
- if (f.startsWith("8-K")) return "current";
150
- if (OWNERSHIP_FORMS.has(f) || f === "SC 13G" || f === "SC 13D" || f === "SC 13G/A" || f === "SC 13D/A") return "ownership";
151
- return "other";
152
- }
153
-
154
109
  function getFormDescription(form: string): string {
155
110
  const f = form.trim().toUpperCase();
156
111
  switch (f) {
@@ -226,7 +181,7 @@ function toFeedItems(
226
181
  });
227
182
  }
228
183
 
229
- export function SecView({ width, height, focused }: Pick<DetailTabProps, "width" | "height" | "focused">) {
184
+ function SecView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
230
185
  const { ticker } = usePaneTicker();
231
186
  const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
232
187
  const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>(selectionKey, 0);
@@ -323,10 +278,10 @@ export function SecView({ width, height, focused }: Pick<DetailTabProps, "width"
323
278
  });
324
279
 
325
280
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view SEC filings.</Text>;
326
- if (!eligibleTicker) return renderNotice("SEC filings are only shown for US equities.", width);
281
+ if (!eligibleTicker) return renderFilingNotice("SEC filings are only shown for US equities.", width);
327
282
  if (loading && filings.length === 0) return <Spinner label="Loading SEC filings..." />;
328
- if (error) return renderNotice(`Error: ${error}`, width);
329
- if (filings.length === 0) return renderNotice(`No recent SEC filings for ${ticker.metadata.ticker}.`, width);
283
+ if (error) return renderFilingNotice(`Error: ${error}`, width);
284
+ if (filings.length === 0) return renderFilingNotice(`No recent SEC filings for ${ticker.metadata.ticker}.`, width);
330
285
 
331
286
  return (
332
287
  <FeedDataTableStackView
@@ -344,14 +299,6 @@ export function SecView({ width, height, focused }: Pick<DetailTabProps, "width"
344
299
  );
345
300
  }
346
301
 
347
- function SecTab(props: DetailTabProps) {
348
- return <SecView {...props} />;
349
- }
350
-
351
- function SecPane({ focused, width, height }: PaneProps) {
352
- return <SecView focused={focused} width={width} height={height} />;
353
- }
354
-
355
302
  export const secPlugin: GloomPlugin = {
356
303
  id: "sec",
357
304
  name: "SEC",
@@ -364,7 +311,7 @@ export const secPlugin: GloomPlugin = {
364
311
  id: "sec",
365
312
  name: "SEC",
366
313
  icon: "S",
367
- component: SecPane,
314
+ component: SecView,
368
315
  defaultPosition: "right",
369
316
  defaultMode: "floating",
370
317
  defaultFloatingSize: { width: 100, height: 32 },
@@ -388,7 +335,7 @@ export const secPlugin: GloomPlugin = {
388
335
  id: "sec",
389
336
  name: "SEC",
390
337
  order: 45,
391
- component: SecTab,
338
+ component: SecView,
392
339
  isVisible: ({ ticker }) => isUsEquityTicker(ticker),
393
340
  });
394
341
  },
@@ -109,7 +109,7 @@ function nextSortPreference(current: SectorSortPreference, columnId: string): Se
109
109
  return DEFAULT_SORT_PREFERENCE;
110
110
  }
111
111
 
112
- export function SectorPerformancePane({ focused, width, height }: PaneProps) {
112
+ function SectorPerformancePane({ focused, width, height }: PaneProps) {
113
113
  const dataProvider = useAssetData();
114
114
  const { navigateTicker } = usePluginTickerActions();
115
115
  const [rows, setRows] = useState<SectorRow[]>(
@@ -90,7 +90,6 @@ export function ChartTab({
90
90
  interactive={interactive}
91
91
  onActivate={onActivate}
92
92
  axisMode={axisMode}
93
- symbol={symbol}
94
93
  ticker={ticker}
95
94
  financials={financials}
96
95
  indicatorConfig={hasStoredIndicatorSelection ? selectedIndicatorConfig : undefined}
@@ -1,7 +1,6 @@
1
1
  import type { GloomPlugin } from "../../../types/plugin";
2
2
  import { normalizeTickerInput } from "../../../utils/ticker-search";
3
3
  import { TickerDetailPane } from "./pane";
4
- import { FinancialsTab } from "./financials-tab";
5
4
  import { QuoteMonitorPane } from "./quote-monitor";
6
5
  import {
7
6
  buildQuoteMonitorSettingsDef,
@@ -560,7 +560,6 @@ export function OverviewTab({
560
560
  height={10}
561
561
  focused={false}
562
562
  compact
563
- symbol={symbol}
564
563
  ticker={ticker}
565
564
  financials={financials}
566
565
  />
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../ui";
2
2
  import { useShortcut } from "../../../react/input";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import type { DetailTabDef, PaneProps } from "../../../types/plugin";
5
5
  import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types";
6
6
  import {
@@ -33,6 +33,29 @@ function sameStringSet(left: Set<string>, right: Set<string>): boolean {
33
33
  return true;
34
34
  }
35
35
 
36
+ function registryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
37
+ if (!registry) return "";
38
+ return [...registry.detailTabs.values()]
39
+ .map((tab) => `${tab.id}:${tab.name}:${tab.order}:${registry.getDetailTabPluginId?.(tab.id) ?? ""}`)
40
+ .join("\0");
41
+ }
42
+
43
+ function useRegistryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
44
+ const subscribe = useCallback((onStoreChange: () => void) => {
45
+ const events = registry?.events;
46
+ if (!events) return () => {};
47
+ const unregisterRegistered = events.on("plugin:registered", onStoreChange);
48
+ const unregisterUnregistered = events.on("plugin:unregistered", onStoreChange);
49
+ return () => {
50
+ unregisterRegistered();
51
+ unregisterUnregistered();
52
+ };
53
+ }, [registry]);
54
+
55
+ const getSnapshot = useCallback(() => registryDetailTabsSnapshot(registry), [registry]);
56
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
57
+ }
58
+
36
59
  export function TickerDetailPane({ focused, width, height }: PaneProps) {
37
60
  const dispatch = useAppDispatch();
38
61
  const config = useAppSelector((state) => state.config);
@@ -54,6 +77,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
54
77
 
55
78
  const disabledPlugins = config.disabledPlugins;
56
79
  const registry = getSharedRegistry();
80
+ const detailTabsSnapshot = useRegistryDetailTabsSnapshot(registry);
57
81
  const pluginTabs = useMemo<DetailTabDef[]>(() => (
58
82
  registry
59
83
  ? [...registry.detailTabs.values()].filter((tab) => {
@@ -61,7 +85,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
61
85
  return !ownerId || !disabledPlugins.includes(ownerId);
62
86
  })
63
87
  : []
64
- ), [disabledPlugins, registry]);
88
+ ), [disabledPlugins, registry, detailTabsSnapshot]);
65
89
  const allTabs = buildVisibleDetailTabs(pluginTabs, ticker, financials, {
66
90
  config,
67
91
  hasOptionsChain,
@@ -84,26 +108,17 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
84
108
  return next;
85
109
  }, [mountedTabIds, resolvedTabId, visibleTabIds]);
86
110
 
87
- const allTabsRef = useRef(allTabs);
88
- allTabsRef.current = allTabs;
89
-
90
111
  const stateRef = useRef({
91
112
  focused,
92
113
  chartInteractive,
93
114
  pluginCaptured,
94
115
  activeTabId: resolvedTabId,
95
- tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
96
- allTabCount: allTabs.length,
97
- hideTabs: paneSettings.hideTabs,
98
116
  });
99
117
  stateRef.current = {
100
118
  focused,
101
119
  chartInteractive,
102
120
  pluginCaptured,
103
121
  activeTabId: resolvedTabId,
104
- tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
105
- allTabCount: allTabs.length,
106
- hideTabs: paneSettings.hideTabs,
107
122
  };
108
123
 
109
124
  const setChartInteractiveEager = useCallback((value: boolean) => {
@@ -162,17 +177,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
162
177
  if (currentState.chartInteractive) return;
163
178
  }
164
179
 
165
- if (currentState.hideTabs) return;
166
-
167
- const tabs = allTabsRef.current;
168
- if (event.name === "h" || event.name === "left") {
169
- const nextIndex = Math.max(currentState.tabIdx - 1, 0);
170
- setActiveTabId(tabs[nextIndex]!.id);
171
- } else if (event.name === "l" || event.name === "right") {
172
- const nextIndex = Math.min(currentState.tabIdx + 1, currentState.allTabCount - 1);
173
- setActiveTabId(tabs[nextIndex]!.id);
174
- }
175
- }, [setActiveTabId, setChartInteractiveEager]);
180
+ }, [setChartInteractiveEager]);
176
181
 
177
182
  useShortcut(handleKeyboard);
178
183
 
@@ -196,6 +201,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
196
201
  tabs={allTabs.map((tab) => ({ label: tab.name, value: tab.id }))}
197
202
  activeValue={resolvedTabId}
198
203
  onSelect={setActiveTabId}
204
+ focused={focused && !pluginCaptured && !(resolvedTabId === "chart" && chartInteractive)}
199
205
  />
200
206
  )}
201
207