gloomberb 0.7.2 → 0.7.3

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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -1,23 +1,38 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { DataTableView, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { Box } from "../../../ui";
3
+ import { DataTableView, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
3
4
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
5
+ import type { PricePoint } from "../../../types/financials";
4
6
  import { colors, priceColor } from "../../../theme/colors";
5
7
  import { formatCurrency, formatPercentRaw } from "../../../utils/format";
6
- import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
7
- import { SECTORS, type SectorDef } from "./sector-data";
8
+ import { useAssetData, useDebouncedPluginPaneState, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
9
+ import {
10
+ SECTOR_COLLECTIONS,
11
+ getSectorCollection,
12
+ type SectorCollectionId,
13
+ type SectorDef,
14
+ } from "./sector-data";
8
15
 
9
16
  const REFRESH_INTERVAL_MS = 60_000;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const ONE_MONTH_DAYS = 30;
19
+ const ONE_YEAR_DAYS = 365;
20
+ const DEFAULT_COLLECTION_ID: SectorCollectionId = "sectors";
10
21
 
11
22
  interface SectorRow extends SectorDef {
12
23
  price: number | null;
13
24
  changePercent: number | null;
25
+ return1M: number | null;
26
+ return1Y: number | null;
14
27
  currency: string;
15
28
  loading: boolean;
16
29
  }
17
30
 
18
- type SectorColumnId = "name" | "etf" | "price" | "changePercent" | "bar";
31
+ type SectorColumnId = "name" | "etf" | "price" | "changePercent" | "return1M" | "return1Y" | "bar";
19
32
  type SectorColumn = DataTableColumn & { id: SectorColumnId };
20
33
  type SortDirection = "asc" | "desc";
34
+ type SectorRowsByCollection = Record<SectorCollectionId, SectorRow[]>;
35
+ type SectorRefreshByCollection = Partial<Record<SectorCollectionId, number>>;
21
36
 
22
37
  interface SectorSortPreference {
23
38
  columnId: SectorColumnId;
@@ -40,22 +55,130 @@ function formatTime(date: Date): string {
40
55
  return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
41
56
  }
42
57
 
58
+ function createLoadingRows(sectors: readonly SectorDef[]): SectorRow[] {
59
+ return sectors.map((sector) => ({
60
+ ...sector,
61
+ price: null,
62
+ changePercent: null,
63
+ return1M: null,
64
+ return1Y: null,
65
+ currency: "USD",
66
+ loading: true,
67
+ }));
68
+ }
69
+
70
+ function createRowsByCollection(): SectorRowsByCollection {
71
+ return {
72
+ sectors: createLoadingRows(getSectorCollection("sectors").items),
73
+ industries: createLoadingRows(getSectorCollection("industries").items),
74
+ };
75
+ }
76
+
77
+ const INITIAL_ROWS_BY_COLLECTION = createRowsByCollection();
78
+ const INITIAL_REFRESH_BY_COLLECTION: SectorRefreshByCollection = {};
79
+
80
+ function normalizeRowsForCollection(
81
+ rowsByCollection: SectorRowsByCollection,
82
+ collectionId: SectorCollectionId,
83
+ ): SectorRow[] {
84
+ const collection = getSectorCollection(collectionId);
85
+ const rows = rowsByCollection[collectionId] ?? [];
86
+ return collection.items.map((sector) => {
87
+ const existing = rows.find((row) => row.etf === sector.etf);
88
+ return {
89
+ ...sector,
90
+ price: existing?.price ?? null,
91
+ changePercent: existing?.changePercent ?? null,
92
+ return1M: existing?.return1M ?? null,
93
+ return1Y: existing?.return1Y ?? null,
94
+ currency: existing?.currency ?? "USD",
95
+ loading: existing?.loading ?? true,
96
+ };
97
+ });
98
+ }
99
+
100
+ function updateRowsForCollection(
101
+ rowsByCollection: SectorRowsByCollection,
102
+ collectionId: SectorCollectionId,
103
+ updater: (rows: SectorRow[]) => SectorRow[],
104
+ ): SectorRowsByCollection {
105
+ return {
106
+ ...rowsByCollection,
107
+ [collectionId]: updater(normalizeRowsForCollection(rowsByCollection, collectionId)),
108
+ };
109
+ }
110
+
111
+ function getPricePointTimestamp(point: PricePoint): number {
112
+ const value = point.date as Date | string | number | null | undefined;
113
+ if (value instanceof Date) return value.getTime();
114
+ if (value == null) return Number.NaN;
115
+ return new Date(value).getTime();
116
+ }
117
+
118
+ function getSortedHistory(history: readonly PricePoint[]): Array<{ point: PricePoint; timestamp: number }> {
119
+ return history
120
+ .map((point) => ({ point, timestamp: getPricePointTimestamp(point) }))
121
+ .filter(({ point, timestamp }) => (
122
+ Number.isFinite(timestamp)
123
+ && Number.isFinite(point.close)
124
+ && point.close > 0
125
+ ))
126
+ .sort((left, right) => left.timestamp - right.timestamp);
127
+ }
128
+
129
+ function latestHistoryClose(history: readonly PricePoint[]): number | null {
130
+ return getSortedHistory(history).at(-1)?.point.close ?? null;
131
+ }
132
+
133
+ export function computeTrailingReturn(
134
+ history: readonly PricePoint[],
135
+ days: number,
136
+ latestPrice?: number | null,
137
+ ): number | null {
138
+ const points = getSortedHistory(history);
139
+ if (points.length < 2) return null;
140
+
141
+ const latest = points.at(-1)!;
142
+ const endPrice = latestPrice != null && Number.isFinite(latestPrice) && latestPrice > 0
143
+ ? latestPrice
144
+ : latest.point.close;
145
+ const targetTimestamp = latest.timestamp - days * DAY_MS;
146
+ let baseline = points[0]!;
147
+ for (const point of points) {
148
+ if (point.timestamp > targetTimestamp) break;
149
+ baseline = point;
150
+ }
151
+ const baselinePrice = baseline.point.close;
152
+ if (!Number.isFinite(endPrice) || !Number.isFinite(baselinePrice) || baselinePrice <= 0) return null;
153
+ return (endPrice / baselinePrice - 1) * 100;
154
+ }
155
+
43
156
  function buildColumns(width: number): SectorColumn[] {
44
- const etfWidth = 6;
45
- const priceWidth = 11;
46
- const changeWidth = 9;
47
- const barWidth = Math.max(4, Math.min(24, Math.floor(width * 0.24)));
48
- const columnCount = 5;
49
- const fixedWidth = etfWidth + priceWidth + changeWidth + barWidth;
50
- const nameWidth = Math.max(12, width - 2 - columnCount - fixedWidth);
51
-
52
- return [
157
+ const etfWidth = 4;
158
+ const priceWidth = 8;
159
+ const changeWidth = 8;
160
+ const returnWidth = 8;
161
+ const showBar = width >= 67;
162
+ const compactBar = width < 82;
163
+ const barWidth = showBar
164
+ ? compactBar ? 6 : Math.max(8, Math.min(18, Math.floor(width * 0.16)))
165
+ : 0;
166
+ const columnCount = showBar ? 7 : 6;
167
+ const fixedWidth = etfWidth + priceWidth + changeWidth + returnWidth * 2 + barWidth;
168
+ const nameWidth = Math.max(12, Math.min(22, width - 2 - columnCount - fixedWidth));
169
+
170
+ const columns: SectorColumn[] = [
53
171
  { id: "name", label: "SECTOR", width: nameWidth, align: "left" },
54
172
  { id: "etf", label: "ETF", width: etfWidth, align: "left" },
55
173
  { id: "price", label: "LAST", width: priceWidth, align: "right" },
56
- { id: "changePercent", label: "CHG%", width: changeWidth, align: "right" },
57
- { id: "bar", label: "MOVE", width: barWidth, align: "left" },
174
+ { id: "changePercent", label: "1D", width: changeWidth, align: "right" },
175
+ { id: "return1M", label: "1M", width: returnWidth, align: "right" },
176
+ { id: "return1Y", label: "1Y", width: returnWidth, align: "right" },
58
177
  ];
178
+ if (showBar) {
179
+ columns.push({ id: "bar", label: "MOVE", width: barWidth, align: "left" });
180
+ }
181
+ return columns;
59
182
  }
60
183
 
61
184
  function getSortValue(columnId: SectorColumnId, row: SectorRow): string | number | null {
@@ -67,6 +190,11 @@ function getSortValue(columnId: SectorColumnId, row: SectorRow): string | number
67
190
  case "price":
68
191
  return row.price;
69
192
  case "changePercent":
193
+ return row.changePercent;
194
+ case "return1M":
195
+ return row.return1M;
196
+ case "return1Y":
197
+ return row.return1Y;
70
198
  case "bar":
71
199
  return row.changePercent;
72
200
  }
@@ -100,7 +228,12 @@ function nextSortPreference(current: SectorSortPreference, columnId: string): Se
100
228
  if (current.columnId !== typedColumnId) {
101
229
  return {
102
230
  columnId: typedColumnId,
103
- direction: typedColumnId === "changePercent" || typedColumnId === "bar" ? "desc" : "asc",
231
+ direction: typedColumnId === "changePercent"
232
+ || typedColumnId === "return1M"
233
+ || typedColumnId === "return1Y"
234
+ || typedColumnId === "bar"
235
+ ? "desc"
236
+ : "asc",
104
237
  };
105
238
  }
106
239
  if (current.direction === "desc") {
@@ -112,20 +245,39 @@ function nextSortPreference(current: SectorSortPreference, columnId: string): Se
112
245
  function SectorPerformancePane({ focused, width, height }: PaneProps) {
113
246
  const dataProvider = useAssetData();
114
247
  const { navigateTicker } = usePluginTickerActions();
115
- const [rows, setRows] = useState<SectorRow[]>(
116
- SECTORS.map((sector) => ({ ...sector, price: null, changePercent: null, currency: "USD", loading: true })),
248
+ const [activeCollectionId, setActiveCollectionId] = usePluginPaneState<SectorCollectionId>(
249
+ "activeCollectionId",
250
+ DEFAULT_COLLECTION_ID,
251
+ );
252
+ const activeCollection = getSectorCollection(activeCollectionId);
253
+ const [rowsByCollection, setRowsByCollection] = useDebouncedPluginPaneState<SectorRowsByCollection>(
254
+ "rowsByCollection:v1",
255
+ INITIAL_ROWS_BY_COLLECTION,
256
+ );
257
+ const [lastRefreshByCollection, setLastRefreshByCollection] = useDebouncedPluginPaneState<SectorRefreshByCollection>(
258
+ "lastRefreshByCollection:v1",
259
+ INITIAL_REFRESH_BY_COLLECTION,
117
260
  );
118
261
  const [selectedEtf, setSelectedEtf] = usePluginPaneState<string | null>("selectedEtf", null);
119
262
  const [sortPreference, setSortPreference] = usePluginPaneState<SectorSortPreference>(
120
263
  "sortPreference",
121
264
  DEFAULT_SORT_PREFERENCE,
122
265
  );
123
- const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
124
266
 
125
267
  const fetchGenRef = useRef(0);
126
268
 
127
269
  const columns = useMemo(() => buildColumns(width), [width]);
270
+ const rows = useMemo(
271
+ () => normalizeRowsForCollection(rowsByCollection, activeCollection.id),
272
+ [activeCollection.id, rowsByCollection],
273
+ );
128
274
  const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
275
+ const lastRefreshMs = lastRefreshByCollection[activeCollection.id];
276
+ const lastRefreshText = lastRefreshMs ? formatTime(new Date(lastRefreshMs)) : "loading";
277
+ const tabs = useMemo(() => SECTOR_COLLECTIONS.map((collection) => ({
278
+ label: collection.label,
279
+ value: collection.id,
280
+ })), []);
129
281
  const selectedIdx = selectedEtf
130
282
  ? sortedRows.findIndex((row) => row.etf === selectedEtf)
131
283
  : -1;
@@ -135,45 +287,66 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
135
287
  fetchGenRef.current += 1;
136
288
  const gen = fetchGenRef.current;
137
289
  if (!dataProvider) {
138
- setRows((prev) => prev.map((row) => ({ ...row, loading: false })));
290
+ setRowsByCollection((prev) => updateRowsForCollection(prev, activeCollection.id, (rows) => (
291
+ rows.map((row) => ({ ...row, loading: false }))
292
+ )));
139
293
  return;
140
294
  }
141
295
 
142
- setRows((prev) => prev.map((row) => ({ ...row, loading: true })));
296
+ const sectorDefs = activeCollection.items;
297
+ const collectionId = activeCollection.id;
143
298
 
144
- const fetches = SECTORS.map(async (sector) => {
299
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
300
+ rows.map((row) => ({ ...row, loading: true }))
301
+ )));
302
+
303
+ const fetches = sectorDefs.map(async (sector) => {
145
304
  try {
146
- const quote = await dataProvider.getQuote(sector.etf, "");
305
+ const [quoteResult, historyResult] = await Promise.allSettled([
306
+ dataProvider.getQuote(sector.etf, ""),
307
+ dataProvider.getPriceHistory(sector.etf, "", "1Y"),
308
+ ]);
147
309
  if (fetchGenRef.current !== gen) return;
148
- setRows((prev) =>
149
- prev.map((row) =>
310
+ const quote = quoteResult.status === "fulfilled" ? quoteResult.value : null;
311
+ const history = historyResult.status === "fulfilled" ? historyResult.value : [];
312
+ const historyClose = latestHistoryClose(history);
313
+ const price = quote?.price ?? historyClose;
314
+ const return1M = computeTrailingReturn(history, ONE_MONTH_DAYS, price);
315
+ const return1Y = computeTrailingReturn(history, ONE_YEAR_DAYS, price);
316
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
317
+ rows.map((row) =>
150
318
  row.etf === sector.etf
151
319
  ? {
152
320
  ...row,
153
- price: quote?.price ?? null,
321
+ price,
154
322
  changePercent: quote?.changePercent ?? null,
323
+ return1M,
324
+ return1Y,
155
325
  currency: quote?.currency ?? "USD",
156
326
  loading: false,
157
327
  }
158
328
  : row,
159
- ),
160
- );
329
+ )
330
+ )));
161
331
  } catch {
162
332
  if (fetchGenRef.current !== gen) return;
163
- setRows((prev) =>
164
- prev.map((row) =>
333
+ setRowsByCollection((prev) => updateRowsForCollection(prev, collectionId, (rows) => (
334
+ rows.map((row) =>
165
335
  row.etf === sector.etf ? { ...row, loading: false } : row,
166
- ),
167
- );
336
+ )
337
+ )));
168
338
  }
169
339
  });
170
340
 
171
341
  Promise.allSettled(fetches).then(() => {
172
342
  if (fetchGenRef.current === gen) {
173
- setLastRefresh(new Date());
343
+ setLastRefreshByCollection((prev) => ({
344
+ ...prev,
345
+ [collectionId]: Date.now(),
346
+ }));
174
347
  }
175
348
  });
176
- }, [dataProvider]);
349
+ }, [activeCollection, dataProvider, setLastRefreshByCollection, setRowsByCollection]);
177
350
 
178
351
  useEffect(() => {
179
352
  fetchAll();
@@ -181,6 +354,11 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
181
354
  return () => clearInterval(interval);
182
355
  }, [fetchAll]);
183
356
 
357
+ useEffect(() => {
358
+ if (activeCollectionId === activeCollection.id) return;
359
+ setActiveCollectionId(activeCollection.id);
360
+ }, [activeCollection.id, activeCollectionId, setActiveCollectionId]);
361
+
184
362
  useEffect(() => {
185
363
  if (selectedEtf && sortedRows.some((row) => row.etf === selectedEtf)) return;
186
364
  const firstRow = sortedRows[0];
@@ -244,6 +422,16 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
244
422
  text: row.changePercent !== null ? formatPercentRaw(row.changePercent) : "—",
245
423
  color: selectedColor ?? (row.changePercent !== null ? priceColor(row.changePercent) : colors.textDim),
246
424
  };
425
+ case "return1M":
426
+ return {
427
+ text: row.loading && row.return1M === null ? "…" : row.return1M !== null ? formatPercentRaw(row.return1M) : "—",
428
+ color: selectedColor ?? (row.return1M !== null ? priceColor(row.return1M) : colors.textDim),
429
+ };
430
+ case "return1Y":
431
+ return {
432
+ text: row.loading && row.return1Y === null ? "…" : row.return1Y !== null ? formatPercentRaw(row.return1Y) : "—",
433
+ color: selectedColor ?? (row.return1Y !== null ? priceColor(row.return1Y) : colors.textDim),
434
+ };
247
435
  case "bar": {
248
436
  const bar = row.changePercent !== null ? buildBar(row.changePercent, column.width) : "";
249
437
  const barColor = row.changePercent !== null && row.changePercent >= 0 ? colors.positive : colors.negative;
@@ -257,13 +445,34 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
257
445
 
258
446
  usePaneFooter("sectors", () => ({
259
447
  info: [
448
+ {
449
+ id: "collection",
450
+ parts: [{ text: activeCollection.label, tone: "label" }],
451
+ },
260
452
  {
261
453
  id: "updated",
262
- parts: [{ text: lastRefresh ? formatTime(lastRefresh) : "loading", tone: lastRefresh ? "value" : "muted" }],
454
+ parts: [{ text: lastRefreshText, tone: lastRefreshMs ? "value" : "muted" }],
263
455
  },
264
456
  ],
265
457
  hints: [{ id: "refresh", key: "r", label: "efresh", onPress: fetchAll }],
266
- }), [fetchAll, lastRefresh]);
458
+ }), [activeCollection.label, fetchAll, lastRefreshMs, lastRefreshText]);
459
+
460
+ const rootBefore = (
461
+ <Box height={1} paddingX={1}>
462
+ <Tabs
463
+ tabs={tabs}
464
+ activeValue={activeCollection.id}
465
+ onSelect={(value) => {
466
+ const nextId = value as SectorCollectionId;
467
+ setActiveCollectionId(nextId);
468
+ setSelectedEtf(null);
469
+ }}
470
+ compact
471
+ variant="bare"
472
+ focused={focused}
473
+ />
474
+ </Box>
475
+ );
267
476
 
268
477
  return (
269
478
  <DataTableView<SectorRow, SectorColumn>
@@ -272,8 +481,10 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
272
481
  onSelectIndex={selectIndex}
273
482
  onActivateIndex={(_index, row) => openRow(row)}
274
483
  onRootKeyDown={handleTableKeyDown}
484
+ rootBefore={rootBefore}
275
485
  rootWidth={width}
276
486
  rootHeight={height}
487
+ resetScrollKey={activeCollection.id}
277
488
  columns={columns}
278
489
  items={sortedRows}
279
490
  sortColumnId={sortPreference.columnId}
@@ -293,7 +504,7 @@ export const sectorsPlugin: GloomPlugin = {
293
504
  id: "sectors",
294
505
  name: "Sector Performance",
295
506
  version: "1.0.0",
296
- description: "S&P 500 sector performance via sector ETF proxies",
507
+ description: "S&P 500 sector and industry performance via ETF proxies",
297
508
  toggleable: true,
298
509
 
299
510
  panes: [
@@ -304,7 +515,7 @@ export const sectorsPlugin: GloomPlugin = {
304
515
  component: SectorPerformancePane,
305
516
  defaultPosition: "right",
306
517
  defaultMode: "floating",
307
- defaultFloatingSize: { width: 75, height: 16 },
518
+ defaultFloatingSize: { width: 82, height: 18 },
308
519
  },
309
520
  ],
310
521
 
@@ -313,8 +524,8 @@ export const sectorsPlugin: GloomPlugin = {
313
524
  id: "sectors-pane",
314
525
  paneId: "sectors",
315
526
  label: "Sector Performance",
316
- description: "S&P 500 sector performance sorted by daily change.",
317
- keywords: ["sector", "sectors", "etf", "xlk", "xlv", "xlf", "performance", "spdr"],
527
+ description: "S&P 500 sector and industry performance sorted by daily change.",
528
+ keywords: ["sector", "sectors", "industry", "semis", "defense", "food", "leisure", "etf", "xlk", "xlv", "xlf", "performance", "spdr"],
318
529
  shortcut: { prefix: "BI" },
319
530
  },
320
531
  ],
@@ -4,7 +4,15 @@ export interface SectorDef {
4
4
  etf: string;
5
5
  }
6
6
 
7
- export const SECTORS: SectorDef[] = [
7
+ export type SectorCollectionId = "sectors" | "industries";
8
+
9
+ export interface SectorCollection {
10
+ id: SectorCollectionId;
11
+ label: string;
12
+ items: SectorDef[];
13
+ }
14
+
15
+ const CORE_SECTORS: SectorDef[] = [
8
16
  { name: "Technology", etf: "XLK" },
9
17
  { name: "Healthcare", etf: "XLV" },
10
18
  { name: "Financials", etf: "XLF" },
@@ -17,3 +25,35 @@ export const SECTORS: SectorDef[] = [
17
25
  { name: "Real Estate", etf: "XLRE" },
18
26
  { name: "Materials", etf: "XLB" },
19
27
  ];
28
+
29
+ const INDUSTRIES: SectorDef[] = [
30
+ { name: "Semiconductors", etf: "SMH" },
31
+ { name: "Software", etf: "IGV" },
32
+ { name: "Cybersecurity", etf: "CIBR" },
33
+ { name: "Aero/Defense", etf: "ITA" },
34
+ { name: "Banks", etf: "KBE" },
35
+ { name: "Regional Banks", etf: "KRE" },
36
+ { name: "Biotech", etf: "XBI" },
37
+ { name: "Pharma", etf: "PPH" },
38
+ { name: "Homebuilders", etf: "XHB" },
39
+ { name: "Retail", etf: "XRT" },
40
+ { name: "Food & Bev.", etf: "PBJ" },
41
+ { name: "Leisure", etf: "PEJ" },
42
+ { name: "Transportation", etf: "IYT" },
43
+ { name: "Metals/Mining", etf: "XME" },
44
+ { name: "Oil & Gas E&P", etf: "XOP" },
45
+ { name: "Clean Energy", etf: "ICLN" },
46
+ { name: "Infrastructure", etf: "PAVE" },
47
+ { name: "Gold Miners", etf: "GDX" },
48
+ ];
49
+
50
+ export const SECTOR_COLLECTIONS: SectorCollection[] = [
51
+ { id: "sectors", label: "Sectors", items: CORE_SECTORS },
52
+ { id: "industries", label: "Industries", items: INDUSTRIES },
53
+ ];
54
+
55
+ export const SECTORS = CORE_SECTORS;
56
+
57
+ export function getSectorCollection(id: SectorCollectionId): SectorCollection {
58
+ return SECTOR_COLLECTIONS.find((collection) => collection.id === id) ?? SECTOR_COLLECTIONS[0]!;
59
+ }
@@ -1,4 +1,4 @@
1
- import { Box } from "../../../ui";
1
+ import { Box, useUiCapabilities } from "../../../ui";
2
2
  import { useViewport } from "../../../react/input";
3
3
  import { useCallback, useMemo } from "react";
4
4
  import { ChartIndicatorSelector } from "../../../components/chart/chart-indicator-selector";
@@ -39,11 +39,12 @@ export function ChartTab({
39
39
  financials: TickerFinancials | null;
40
40
  }) {
41
41
  const { width: termWidth, height: termHeight } = useViewport();
42
+ const { fractionalViewport = false } = useUiCapabilities();
42
43
  const [rawIndicatorSelection] = usePluginConfigState<unknown>(CHART_INDICATORS_PLUGIN_CONFIG_KEY, null);
43
44
  const [indicatorSelectionVersion] = usePluginConfigState<unknown>(CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY, null);
44
45
  const setPluginConfigStates = useSetPluginConfigStates();
45
46
 
46
- const chartWidth = Math.max((width || Math.floor(termWidth * 0.55)) - 2, 30);
47
+ const chartWidth = Math.max((width || Math.floor(termWidth * 0.55)) - (fractionalViewport ? 1 : 2), 30);
47
48
  const chartHeight = Math.max(height ?? termHeight - 8, 10);
48
49
  const hasStoredIndicatorSelection = Array.isArray(rawIndicatorSelection);
49
50
  const selectedIndicatorIds = useMemo(
@@ -4,9 +4,11 @@ import { TickerDetailPane } from "./pane";
4
4
  import { QuoteMonitorPane } from "./quote-monitor";
5
5
  import {
6
6
  buildQuoteMonitorSettingsDef,
7
+ buildQuoteMonitorPaneTitle,
7
8
  buildTickerDetailSettingsDef,
8
9
  getTickerDetailPaneSettings,
9
10
  } from "./settings";
11
+ import { formatTickerListInput } from "../../../utils/ticker-list";
10
12
 
11
13
  export { FinancialsTab } from "./financials-tab";
12
14
  export { QuoteMonitorPane } from "./quote-monitor";
@@ -34,7 +36,7 @@ export const tickerDetailPlugin: GloomPlugin = {
34
36
  component: QuoteMonitorPane,
35
37
  defaultPosition: "right",
36
38
  defaultMode: "floating",
37
- defaultFloatingSize: { width: 64, height: 8 },
39
+ defaultFloatingSize: { width: 72, height: 10 },
38
40
  settings: buildQuoteMonitorSettingsDef(),
39
41
  },
40
42
  ],
@@ -59,17 +61,36 @@ export const tickerDetailPlugin: GloomPlugin = {
59
61
  id: "quote-monitor-pane",
60
62
  paneId: "quote-monitor",
61
63
  label: "Quote Monitor",
62
- description: "Open a compact quote monitor for the selected ticker",
64
+ description: "Open a compact quote monitor for one or more tickers",
63
65
  keywords: ["quote", "monitor", "price", "ticker", "pane"],
64
- shortcut: { prefix: "QQ", argPlaceholder: "ticker", argKind: "ticker" },
65
- canCreate: (context, options) => (options?.symbol ?? normalizeTickerInput(context.activeTicker, options?.arg)) !== null,
66
+ shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" },
67
+ wizard: [
68
+ {
69
+ key: "tickers",
70
+ label: "Quote Tickers",
71
+ placeholder: "AAPL, MSFT, NVDA",
72
+ body: ["Enter one or more ticker symbols separated by commas."],
73
+ type: "text",
74
+ },
75
+ ],
76
+ canCreate: (context, options) => (
77
+ (options?.symbols?.length ?? 0) > 0
78
+ || normalizeTickerInput(context.activeTicker, options?.arg) !== null
79
+ ),
66
80
  createInstance: (context, options) => {
67
- const ticker = options?.symbol ?? normalizeTickerInput(context.activeTicker, options?.arg);
68
- return ticker
81
+ const symbols = options?.symbols?.length
82
+ ? options.symbols
83
+ : [normalizeTickerInput(context.activeTicker, options?.arg)].filter((symbol): symbol is string => !!symbol);
84
+ const primarySymbol = symbols[0];
85
+ return primarySymbol
69
86
  ? {
70
- title: ticker,
71
- binding: { kind: "fixed", symbol: ticker },
72
- settings: { symbol: ticker },
87
+ title: buildQuoteMonitorPaneTitle(symbols),
88
+ binding: { kind: "fixed", symbol: primarySymbol },
89
+ settings: {
90
+ symbol: primarySymbol,
91
+ symbols,
92
+ symbolsText: formatTickerListInput(symbols),
93
+ },
73
94
  placement: "floating",
74
95
  }
75
96
  : null;