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
@@ -1,10 +1,11 @@
1
1
  import { Box, Text } from "../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { TextAttributes } from "../../ui";
4
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../types/plugin";
4
+ import type { GloomPlugin } from "../../types/plugin";
5
5
  import type { OptionContract, OptionsChain } from "../../types/financials";
6
6
  import { usePaneTicker } from "../../state/app-context";
7
- import { blendHex, colors } from "../../theme/colors";
7
+ import { blendHex, colors, hoverBg } from "../../theme/colors";
8
+ import { blendForContrast, contrastRatio, higherContrast } from "../../theme/color-utils";
8
9
  import { formatCompact, formatNumber } from "../../utils/format";
9
10
  import { formatMarketPrice } from "../../utils/market-format";
10
11
  import { formatExpDate, resolveOptionsTarget } from "../../utils/options";
@@ -12,7 +13,7 @@ import { useOptionsQuery, useResolvedEntryValue } from "../../market-data/hooks"
12
13
  import { DataTableView, Spinner, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../components";
13
14
  import { createTickerSurfacePaneTemplate } from "./ticker-surface";
14
15
 
15
- type OptionColumnId =
16
+ export type OptionColumnId =
16
17
  | "callLast"
17
18
  | "callBid"
18
19
  | "callAsk"
@@ -36,31 +37,40 @@ interface OptionTableRow {
36
37
  isPositionStrike: boolean;
37
38
  }
38
39
 
39
- type OptionsViewProps = Pick<DetailTabProps, "width" | "height" | "focused"> & {
40
- onCapture?: DetailTabProps["onCapture"];
40
+ type OptionsViewProps = {
41
+ width: number;
42
+ height: number;
43
+ focused: boolean;
44
+ onCapture?: (capturing: boolean) => void;
41
45
  };
42
46
 
43
- const OPTION_CALL_COLOR = "#5ed69a";
44
- const OPTION_PUT_COLOR = "#ff9c7a";
45
- const OPTION_PRICE_COLOR = "#dfc05b";
46
- const OPTION_ACTIVITY_COLOR = "#35a7d6";
47
- const OPTION_IV_COLOR = "#8bd878";
48
- const OPTION_STRIKE_COLOR = "#8fb7ff";
49
-
50
- const OPTION_COLUMNS: OptionColumn[] = [
51
- { id: "callOpenInterest", label: "C OI", width: 6, align: "right", headerColor: OPTION_ACTIVITY_COLOR },
52
- { id: "callVolume", label: "C VOL", width: 6, align: "right", headerColor: OPTION_ACTIVITY_COLOR },
53
- { id: "callLast", label: "C LAST", width: 7, align: "right", headerColor: OPTION_CALL_COLOR },
54
- { id: "callIv", label: "C IV", width: 6, align: "right", headerColor: OPTION_IV_COLOR },
55
- { id: "callBid", label: "C BID", width: 7, align: "right", headerColor: OPTION_PRICE_COLOR },
56
- { id: "callAsk", label: "C ASK", width: 7, align: "right", headerColor: OPTION_PRICE_COLOR },
57
- { id: "strike", label: "STRIKE", width: 9, align: "right", headerColor: OPTION_STRIKE_COLOR },
58
- { id: "putBid", label: "P BID", width: 7, align: "right", headerColor: OPTION_PRICE_COLOR },
59
- { id: "putAsk", label: "P ASK", width: 7, align: "right", headerColor: OPTION_PRICE_COLOR },
60
- { id: "putIv", label: "P IV", width: 6, align: "right", headerColor: OPTION_IV_COLOR },
61
- { id: "putLast", label: "P LAST", width: 7, align: "right", headerColor: OPTION_PUT_COLOR },
62
- { id: "putVolume", label: "P VOL", width: 6, align: "right", headerColor: OPTION_ACTIVITY_COLOR },
63
- { id: "putOpenInterest", label: "P OI", width: 6, align: "right", headerColor: OPTION_ACTIVITY_COLOR },
47
+ type OptionColorRole = "call" | "put" | "price" | "activity" | "iv" | "strike";
48
+
49
+ const OPTION_TEXT_MIN_CONTRAST = 4.5;
50
+
51
+ const OPTION_BASE_COLORS: Record<OptionColorRole, string> = {
52
+ call: "#5ed69a",
53
+ put: "#ff9c7a",
54
+ price: "#dfc05b",
55
+ activity: "#35a7d6",
56
+ iv: "#8bd878",
57
+ strike: "#8fb7ff",
58
+ };
59
+
60
+ const OPTION_COLUMNS: Array<Omit<OptionColumn, "headerColor">> = [
61
+ { id: "callOpenInterest", label: "C OI", width: 6, align: "right" },
62
+ { id: "callVolume", label: "C VOL", width: 6, align: "right" },
63
+ { id: "callLast", label: "C LAST", width: 7, align: "right" },
64
+ { id: "callIv", label: "C IV", width: 6, align: "right" },
65
+ { id: "callBid", label: "C BID", width: 7, align: "right" },
66
+ { id: "callAsk", label: "C ASK", width: 7, align: "right" },
67
+ { id: "strike", label: "STRIKE", width: 9, align: "right" },
68
+ { id: "putBid", label: "P BID", width: 7, align: "right" },
69
+ { id: "putAsk", label: "P ASK", width: 7, align: "right" },
70
+ { id: "putIv", label: "P IV", width: 6, align: "right" },
71
+ { id: "putLast", label: "P LAST", width: 7, align: "right" },
72
+ { id: "putVolume", label: "P VOL", width: 6, align: "right" },
73
+ { id: "putOpenInterest", label: "P OI", width: 6, align: "right" },
64
74
  ];
65
75
 
66
76
  export function OptionsView({ width, height, focused, onCapture = () => {} }: OptionsViewProps) {
@@ -161,6 +171,10 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
161
171
  const selectedStrike = strikes[strikeIdx];
162
172
  const selectedCall = selectedStrike != null ? callsByStrike.get(selectedStrike) : undefined;
163
173
  const selectedPut = selectedStrike != null ? putsByStrike.get(selectedStrike) : undefined;
174
+ const optionColumns = OPTION_COLUMNS.map((column) => ({
175
+ ...column,
176
+ headerColor: optionColumnColor(column.id, colors.panel),
177
+ }));
164
178
 
165
179
  usePaneFooter("options", () => {
166
180
  const info = [
@@ -232,23 +246,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
232
246
  return true;
233
247
  }
234
248
 
235
- if (!interactive) return false;
236
-
237
- const numExp = chain?.expirationDates.length ?? 0;
238
- if (event.name === "h" || event.name === "left") {
239
- event.preventDefault?.();
240
- event.stopPropagation?.();
241
- setExpIdx((i) => Math.max(i - 1, 0));
242
- return true;
243
- }
244
- if (event.name === "l" || event.name === "right") {
245
- event.preventDefault?.();
246
- event.stopPropagation?.();
247
- setExpIdx((i) => Math.min(i + 1, Math.max(numExp - 1, 0)));
248
- return true;
249
- }
250
249
  return false;
251
- }, [chain?.expirationDates.length, enterInteractive, exitInteractive, interactive, strikes.length]);
250
+ }, [enterInteractive, exitInteractive, interactive, strikes.length]);
252
251
 
253
252
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view options.</Text>;
254
253
  if (loading && !chain) return <Spinner label="Loading options chain..." />;
@@ -285,6 +284,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
285
284
  }}
286
285
  compact
287
286
  variant="bare"
287
+ focused={focused && interactive}
288
288
  />
289
289
  </Box>
290
290
  {loading && <Spinner />}
@@ -303,7 +303,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
303
303
  focused={focused}
304
304
  selectedIndex={strikeIdx}
305
305
  onRootKeyDown={handleTableKeyDown}
306
- columns={OPTION_COLUMNS}
306
+ columns={optionColumns}
307
307
  items={rows}
308
308
  sortColumnId={null}
309
309
  sortDirection="asc"
@@ -327,14 +327,6 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
327
327
  );
328
328
  }
329
329
 
330
- export function OptionsTab(props: DetailTabProps) {
331
- return <OptionsView {...props} />;
332
- }
333
-
334
- export function OptionsPane({ focused, width, height }: PaneProps) {
335
- return <OptionsView focused={focused} width={width} height={height} />;
336
- }
337
-
338
330
  function buildStrikeList(chain: OptionsChain): number[] {
339
331
  const set = new Set<number>();
340
332
  for (const c of chain.calls) set.add(c.strike);
@@ -378,12 +370,60 @@ function optionContractForColumn(row: OptionTableRow, columnId: OptionColumnId):
378
370
  return columnId.startsWith("call") ? row.call : row.put;
379
371
  }
380
372
 
381
- function optionColumnColor(columnId: OptionColumnId): string {
382
- if (columnId === "strike") return OPTION_STRIKE_COLOR;
383
- if (columnId.endsWith("Iv")) return OPTION_IV_COLOR;
384
- if (columnId.endsWith("Volume") || columnId.endsWith("OpenInterest")) return OPTION_ACTIVITY_COLOR;
385
- if (columnId.endsWith("Bid") || columnId.endsWith("Ask")) return OPTION_PRICE_COLOR;
386
- return columnId.startsWith("call") ? OPTION_CALL_COLOR : OPTION_PUT_COLOR;
373
+ export function optionColumnColor(columnId: OptionColumnId, surface = colors.bg): string {
374
+ return optionRoleColor(optionColumnRole(columnId), surface);
375
+ }
376
+
377
+ function optionColumnRole(columnId: OptionColumnId): OptionColorRole {
378
+ if (columnId === "strike") return "strike";
379
+ if (columnId.endsWith("Iv")) return "iv";
380
+ if (columnId.endsWith("Volume") || columnId.endsWith("OpenInterest")) return "activity";
381
+ if (columnId.endsWith("Bid") || columnId.endsWith("Ask")) return "price";
382
+ return columnId.startsWith("call") ? "call" : "put";
383
+ }
384
+
385
+ function optionRoleThemeColor(role: OptionColorRole): string {
386
+ switch (role) {
387
+ case "call":
388
+ case "iv":
389
+ return colors.positive;
390
+ case "put":
391
+ return colors.negative;
392
+ case "price":
393
+ return colors.warning;
394
+ case "activity":
395
+ case "strike":
396
+ return colors.borderFocused;
397
+ }
398
+ }
399
+
400
+ function mostReadableColor(surface: string, candidates: readonly string[]): string {
401
+ return candidates.reduce((best, candidate) =>
402
+ contrastRatio(candidate, surface) > contrastRatio(best, surface) ? candidate : best,
403
+ );
404
+ }
405
+
406
+ export function optionRoleColor(role: OptionColorRole, surface: string): string {
407
+ const preferred = OPTION_BASE_COLORS[role];
408
+ const themeColor = optionRoleThemeColor(role);
409
+ const fallback = mostReadableColor(surface, [
410
+ preferred,
411
+ themeColor,
412
+ colors.text,
413
+ colors.textBright,
414
+ higherContrast("#ffffff", "#000000", surface),
415
+ ]);
416
+ return blendForContrast(preferred, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
417
+ }
418
+
419
+ export function optionMutedColor(surface: string): string {
420
+ const fallback = mostReadableColor(surface, [
421
+ colors.textDim,
422
+ colors.text,
423
+ colors.textBright,
424
+ higherContrast("#ffffff", "#000000", surface),
425
+ ]);
426
+ return blendForContrast(colors.textDim, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
387
427
  }
388
428
 
389
429
  function optionMoneynessBackground(
@@ -443,23 +483,28 @@ function renderOptionCell(
443
483
  rowState: { selected: boolean; hovered: boolean },
444
484
  ): DataTableCell {
445
485
  const selectedColor = rowState.selected ? colors.selectedText : undefined;
486
+ const rowSurface = rowState.selected ? colors.selected : rowState.hovered ? hoverBg() : colors.bg;
446
487
 
447
488
  if (column.id === "strike") {
489
+ const backgroundColor = rowState.selected || rowState.hovered
490
+ ? undefined
491
+ : blendHex(colors.bg, row.isPositionStrike ? colors.borderFocused : colors.header, row.isPositionStrike ? 0.18 : 0.1);
492
+ const surface = backgroundColor ?? rowSurface;
448
493
  return {
449
494
  text: formatStrikeLabel(row.strike),
450
- color: selectedColor ?? OPTION_STRIKE_COLOR,
451
- backgroundColor: rowState.selected || rowState.hovered
452
- ? undefined
453
- : blendHex(colors.bg, row.isPositionStrike ? colors.borderFocused : colors.header, row.isPositionStrike ? 0.18 : 0.1),
495
+ color: selectedColor ?? optionRoleColor("strike", surface),
496
+ backgroundColor,
454
497
  attributes: rowState.selected || row.isPositionStrike ? TextAttributes.BOLD : TextAttributes.NONE,
455
498
  };
456
499
  }
457
500
 
458
501
  const contract = optionContractForColumn(row, column.id);
502
+ const backgroundColor = optionMoneynessBackground(row, contract, column.id, rowState);
503
+ const surface = backgroundColor ?? rowSurface;
459
504
  return {
460
505
  text: formatOptionContractCell(contract, column),
461
- color: selectedColor ?? (contract ? optionColumnColor(column.id) : colors.textDim),
462
- backgroundColor: optionMoneynessBackground(row, contract, column.id, rowState),
506
+ color: selectedColor ?? (contract ? optionRoleColor(optionColumnRole(column.id), surface) : optionMutedColor(surface)),
507
+ backgroundColor,
463
508
  };
464
509
  }
465
510
 
@@ -475,7 +520,7 @@ export const optionsPlugin: GloomPlugin = {
475
520
  id: "options",
476
521
  name: "Options",
477
522
  icon: "O",
478
- component: OptionsPane,
523
+ component: OptionsView,
479
524
  defaultPosition: "right",
480
525
  defaultMode: "floating",
481
526
  defaultFloatingSize: { width: 112, height: 28 },
@@ -498,7 +543,7 @@ export const optionsPlugin: GloomPlugin = {
498
543
  id: "options",
499
544
  name: "Options",
500
545
  order: 35, // after Chart (30), before News (40)
501
- component: OptionsTab,
546
+ component: OptionsView,
502
547
  isVisible: ({ hasOptionsChain }) => hasOptionsChain,
503
548
  });
504
549
  },
@@ -4,6 +4,7 @@ import { correlationPlugin } from "./correlation";
4
4
  import { earningsPlugin } from "./earnings";
5
5
  import { registerEconCalendarFeature } from "./econ";
6
6
  import { fxMatrixPlugin } from "./fx-matrix";
7
+ import { fearGreedPlugin } from "./fear-greed";
7
8
  import { holdersPlugin } from "./holders";
8
9
  import { insiderPlugin } from "./insider";
9
10
  import { marketMoversPlugin } from "./market-movers";
@@ -74,6 +75,7 @@ export const marketOverviewPlugin = createPluginGroup({
74
75
  correlationPlugin,
75
76
  worldIndicesPlugin,
76
77
  marketMoversPlugin,
78
+ fearGreedPlugin,
77
79
  sectorsPlugin,
78
80
  fxMatrixPlugin,
79
81
  ],
@@ -16,7 +16,7 @@ import {
16
16
  renderTable,
17
17
  } from "../../../../utils/cli-output";
18
18
  import { exchangeShortName } from "../../../../utils/market-status";
19
- import { formatCompact, formatCurrency } from "../../../../utils/format";
19
+ import { formatCompact } from "../../../../utils/format";
20
20
  import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity } from "../../../../utils/market-format";
21
21
  import type { AppConfig } from "../../../../types/config";
22
22
  import type { CliCommandContext, CliCommandDef } from "../../../../types/plugin";
@@ -282,7 +282,6 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
282
282
  [tickers, financialsMap, activeSort, columnContext, columns],
283
283
  );
284
284
 
285
- const currentTabIdx = visibleCollections.findIndex((collection) => collection.id === activeCollectionId);
286
285
  const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
287
286
  const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
288
287
 
@@ -359,35 +358,15 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
359
358
  return true;
360
359
  }
361
360
 
362
- if (showCollectionTabs && (key === "h" || key === "left")) {
363
- event.preventDefault?.();
364
- event.stopPropagation?.();
365
- const previousCollection = visibleCollections[Math.max(currentTabIdx - 1, 0)];
366
- if (previousCollection) handleCollectionSelect(previousCollection.id);
367
- return true;
368
- }
369
-
370
- if (showCollectionTabs && (key === "l" || key === "right")) {
371
- event.preventDefault?.();
372
- event.stopPropagation?.();
373
- const nextCollection = visibleCollections[Math.min(currentTabIdx + 1, visibleCollections.length - 1)];
374
- if (nextCollection) handleCollectionSelect(nextCollection.id);
375
- return true;
376
- }
377
-
378
361
  return false;
379
362
  }, [
380
363
  cashDrawerExpanded,
381
- currentTabIdx,
382
364
  focused,
383
365
  openTickerFloating,
384
366
  safeSelectedIdx,
385
367
  setCashDrawerExpanded,
386
- handleCollectionSelect,
387
368
  showCashDrawer,
388
- showCollectionTabs,
389
369
  sortedTickers,
390
- visibleCollections,
391
370
  ]);
392
371
 
393
372
  useEffect(() => {
@@ -471,32 +450,29 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
471
450
  if (queue.length === 0) return;
472
451
 
473
452
  let cancelled = false;
474
- const runNext = async (): Promise<void> => {
475
- if (cancelled) return;
476
- const nextTicker = queue.shift();
477
- if (!nextTicker) return;
478
-
479
- const key = `${nextTicker.metadata.ticker}:${nextTicker.metadata.exchange ?? ""}`;
480
- const instrument = instrumentFromTicker(nextTicker, nextTicker.metadata.ticker);
481
- warmupInFlightRef.current.add(key);
482
- warmupAttemptRef.current.set(key, nowTimestamp);
453
+ const runBatch = async (): Promise<void> => {
454
+ const entries = queue.flatMap((ticker) => {
455
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
456
+ if (!instrument) return [];
457
+ const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
458
+ warmupInFlightRef.current.add(key);
459
+ warmupAttemptRef.current.set(key, nowTimestamp);
460
+ return [{ key, instrument }];
461
+ });
462
+ if (entries.length === 0) return;
483
463
  try {
484
- if (instrument) {
485
- await sharedCoordinator.loadSnapshot(instrument);
486
- }
464
+ await sharedCoordinator.loadSnapshotsBatch(entries.map((entry) => entry.instrument));
487
465
  } catch {
488
466
  // Best-effort warmup for visible rows only.
489
467
  } finally {
490
- warmupInFlightRef.current.delete(key);
491
- }
492
-
493
- if (mountedRef.current && !cancelled) {
494
- await runNext();
468
+ for (const entry of entries) {
469
+ warmupInFlightRef.current.delete(entry.key);
470
+ }
495
471
  }
496
472
  };
497
473
 
498
474
  const timeoutId = setTimeout(() => {
499
- void runNext();
475
+ if (!cancelled && mountedRef.current) void runBatch();
500
476
  }, VISIBLE_FINANCIAL_WARMUP_DELAY_MS);
501
477
 
502
478
  return () => {
@@ -590,6 +566,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
590
566
  activeValue={activeCollectionId}
591
567
  onSelect={handleCollectionSelect}
592
568
  compact
569
+ focused={focused}
593
570
  />
594
571
  </Box>
595
572
  </Box>
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AnalystRatingRecord } from "../../../types/financials";
3
+ import {
4
+ nextRatingSortPreference,
5
+ sortRatingRows,
6
+ type RatingSortPreference,
7
+ } from "./index";
8
+
9
+ const ratings: AnalystRatingRecord[] = [
10
+ {
11
+ date: "2026-05-06",
12
+ firm: "Beta Capital",
13
+ action: "Raises",
14
+ current: "Neutral",
15
+ prior: "Neutral",
16
+ currentPriceTarget: 385,
17
+ priorPriceTarget: 270,
18
+ },
19
+ {
20
+ date: "2026-05-07",
21
+ firm: "Alpha Research",
22
+ action: "Downgrade",
23
+ current: "Hold",
24
+ prior: "Buy",
25
+ currentPriceTarget: 340,
26
+ priorPriceTarget: 335,
27
+ },
28
+ {
29
+ date: "2026-05-06",
30
+ firm: "Zenith",
31
+ action: "Upgrade",
32
+ current: "Buy",
33
+ prior: "Neutral",
34
+ currentPriceTarget: 525,
35
+ priorPriceTarget: 265,
36
+ },
37
+ {
38
+ date: "2026-05-05",
39
+ firm: "No Target",
40
+ action: "Reiterates",
41
+ current: "Buy",
42
+ prior: "Buy",
43
+ },
44
+ ];
45
+
46
+ describe("analyst rating sorting", () => {
47
+ test("sorts date newest first by default", () => {
48
+ const preference: RatingSortPreference = { columnId: "date", direction: "desc" };
49
+
50
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
51
+ "Alpha Research",
52
+ "Beta Capital",
53
+ "Zenith",
54
+ "No Target",
55
+ ]);
56
+ });
57
+
58
+ test("sorts target by current target value with missing targets last", () => {
59
+ const preference: RatingSortPreference = { columnId: "target", direction: "desc" };
60
+
61
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
62
+ "Zenith",
63
+ "Beta Capital",
64
+ "Alpha Research",
65
+ "No Target",
66
+ ]);
67
+ });
68
+
69
+ test("sorts text columns alphabetically with recent dates as a tie-breaker", () => {
70
+ const preference: RatingSortPreference = { columnId: "firm", direction: "asc" };
71
+
72
+ expect(sortRatingRows(ratings, preference).map((row) => row.firm)).toEqual([
73
+ "Alpha Research",
74
+ "Beta Capital",
75
+ "No Target",
76
+ "Zenith",
77
+ ]);
78
+ });
79
+
80
+ test("uses sensible first-click directions per column", () => {
81
+ expect(nextRatingSortPreference({ columnId: "date", direction: "desc" }, "date")).toEqual({
82
+ columnId: "date",
83
+ direction: "asc",
84
+ });
85
+ expect(nextRatingSortPreference({ columnId: "date", direction: "desc" }, "target")).toEqual({
86
+ columnId: "target",
87
+ direction: "desc",
88
+ });
89
+ expect(nextRatingSortPreference({ columnId: "target", direction: "desc" }, "firm")).toEqual({
90
+ columnId: "firm",
91
+ direction: "asc",
92
+ });
93
+ });
94
+ });