gloomberb 0.9.2 → 0.9.4

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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,7 +1,7 @@
1
1
  import { Box, ScrollBox, Text } from "../../../ui";
2
2
  import { TextAttributes, type ScrollBoxRenderable } from "../../../ui";
3
3
  import type { RefObject } from "react";
4
- import { Spinner, Tabs } from "../../../components";
4
+ import { Tabs } from "../../../components";
5
5
  import { EmptyState } from "../../../components/ui/status";
6
6
  import { colors } from "../../../theme/colors";
7
7
  import { padTo } from "../../../utils/format";
@@ -37,8 +37,11 @@ interface MetricCell {
37
37
  function MetricLabelRow({ metrics }: { metrics: MetricCell[] }) {
38
38
  return (
39
39
  <Box flexDirection="row" height={1}>
40
- {metrics.map((metric) => (
41
- <Box key={metric.label} width={metric.width + 1}>
40
+ {metrics.map((metric, index) => (
41
+ <Box
42
+ key={metric.label}
43
+ width={metric.width + (index === metrics.length - 1 ? 0 : 1)}
44
+ >
42
45
  <Text fg={colors.textDim}>{padTo(metric.label, metric.width)}</Text>
43
46
  </Box>
44
47
  ))}
@@ -49,8 +52,11 @@ function MetricLabelRow({ metrics }: { metrics: MetricCell[] }) {
49
52
  function MetricValueRow({ metrics }: { metrics: MetricCell[] }) {
50
53
  return (
51
54
  <Box flexDirection="row" height={1}>
52
- {metrics.map((metric) => (
53
- <Box key={metric.label} width={metric.width + 1}>
55
+ {metrics.map((metric, index) => (
56
+ <Box
57
+ key={metric.label}
58
+ width={metric.width + (index === metrics.length - 1 ? 0 : 1)}
59
+ >
54
60
  <Text
55
61
  fg={metric.color ?? colors.textBright}
56
62
  attributes={TextAttributes.BOLD}
@@ -108,8 +114,6 @@ export function PredictionMarketDetailPane({
108
114
  }
109
115
 
110
116
  const summaryMetrics = detail?.summary ?? selectedSummary;
111
- const detailTitle =
112
- selectedRow?.kind === "group" ? selectedRow.title : summaryMetrics.title;
113
117
  const detailSubtitleParts: string[] = [];
114
118
  if (selectedRow?.kind === "group") {
115
119
  if (summaryMetrics.category) detailSubtitleParts.push(summaryMetrics.category);
@@ -126,18 +130,18 @@ export function PredictionMarketDetailPane({
126
130
  );
127
131
  if (endsLabel !== "—") detailSubtitleParts.push(endsLabel);
128
132
  const detailSubtitle = detailSubtitleParts.join(" · ");
129
- const primaryMetrics: MetricCell[] = [
133
+ const metrics: MetricCell[] = [
130
134
  {
131
135
  label: "YES",
132
136
  value: formatPredictionProbability(summaryMetrics.yesPrice),
133
137
  color: getPredictionProbabilityColor(summaryMetrics.yesPrice),
134
- width: 12,
138
+ width: 6,
135
139
  },
136
140
  {
137
141
  label: "NO",
138
142
  value: formatPredictionProbability(summaryMetrics.noPrice),
139
143
  color: getPredictionProbabilityColor(summaryMetrics.noPrice),
140
- width: 12,
144
+ width: 6,
141
145
  },
142
146
  {
143
147
  label: "24H VOL",
@@ -145,7 +149,7 @@ export function PredictionMarketDetailPane({
145
149
  summaryMetrics.volume24h,
146
150
  summaryMetrics.volume24hUnit ?? "usd",
147
151
  ),
148
- width: 16,
152
+ width: 8,
149
153
  },
150
154
  {
151
155
  label: "TOTAL VOL",
@@ -153,28 +157,26 @@ export function PredictionMarketDetailPane({
153
157
  summaryMetrics.totalVolume,
154
158
  summaryMetrics.totalVolumeUnit ?? "usd",
155
159
  ),
156
- width: 16,
160
+ width: 9,
157
161
  },
158
- ];
159
- const secondaryMetrics: MetricCell[] = [
160
162
  {
161
163
  label: "OI",
162
164
  value: formatPredictionMetric(
163
165
  summaryMetrics.openInterest,
164
166
  summaryMetrics.openInterestUnit ?? "usd",
165
167
  ),
166
- width: 16,
168
+ width: 7,
167
169
  },
168
170
  {
169
171
  label: "SPREAD",
170
172
  value: formatPredictionSpread(summaryMetrics.spread),
171
- width: 16,
173
+ width: 7,
172
174
  },
173
175
  {
174
176
  label: "LAST",
175
177
  value: formatPredictionProbability(summaryMetrics.lastTradePrice),
176
178
  color: colors.textBright,
177
- width: 16,
179
+ width: 7,
178
180
  },
179
181
  ];
180
182
  const relatedSiblings =
@@ -186,33 +188,25 @@ export function PredictionMarketDetailPane({
186
188
  0,
187
189
  Math.max(Math.min(Math.floor((detailWidth - 10) / 18), 3), 0),
188
190
  ) ?? []);
189
- const headerHeight = detailSubtitle.length > 0 ? 3 : 2;
191
+ const headerHeight = detailSubtitle.length > 0 ? 2 : 0;
190
192
  const detailLoading = detailLoadCount > 0 && !detail;
191
- const titleColor = focused ? colors.textBright : colors.text;
192
193
  const detailTextWidth = Math.max(detailWidth, 12);
193
194
 
194
195
  return (
195
196
  <>
196
- <Box flexDirection="column" height={headerHeight} paddingBottom={1}>
197
- <Box flexDirection="row" height={1}>
198
- <Text fg={titleColor} attributes={TextAttributes.BOLD}>
199
- {truncatePredictionText(detailTitle, detailTextWidth)}
200
- </Text>
201
- </Box>
202
- {detailSubtitle.length > 0 && (
197
+ {headerHeight > 0 && (
198
+ <Box flexDirection="column" height={headerHeight} paddingBottom={1}>
203
199
  <Box flexDirection="row" height={1}>
204
200
  <Text fg={colors.textDim}>
205
201
  {truncatePredictionText(detailSubtitle, detailTextWidth)}
206
202
  </Text>
207
203
  </Box>
208
- )}
209
- </Box>
204
+ </Box>
205
+ )}
210
206
 
211
- <Box flexDirection="column" height={4} paddingBottom={1}>
212
- <MetricLabelRow metrics={primaryMetrics} />
213
- <MetricValueRow metrics={primaryMetrics} />
214
- <MetricLabelRow metrics={secondaryMetrics} />
215
- <MetricValueRow metrics={secondaryMetrics} />
207
+ <Box flexDirection="column" height={3} paddingBottom={1}>
208
+ <MetricLabelRow metrics={metrics} />
209
+ <MetricValueRow metrics={metrics} />
216
210
  </Box>
217
211
 
218
212
  {relatedSiblings.length > 0 && (
@@ -260,43 +254,56 @@ export function PredictionMarketDetailPane({
260
254
  </Box>
261
255
  )}
262
256
 
263
- <ScrollBox ref={scrollRef} flexGrow={1} scrollY>
264
- {detailLoading && (
265
- <Box height={1} paddingBottom={1}>
266
- <Spinner label="Loading market detail..." />
267
- </Box>
268
- )}
269
- {detailTab === "overview" && (
270
- <PredictionMarketOverviewView
271
- detail={detail}
272
- detailWidth={detailWidth}
273
- height={height}
274
- historyRange={historyRange}
275
- onHistoryRangeChange={onHistoryRangeChange}
276
- onSelectMarket={onSelectMarket}
277
- selectedRow={selectedRow}
278
- summary={summaryMetrics}
279
- />
280
- )}
257
+ {detailTab === "overview" || detailTab === "rules" ? (
258
+ <ScrollBox ref={scrollRef} flexGrow={1} scrollY>
259
+ {detailTab === "overview" && (
260
+ <PredictionMarketOverviewView
261
+ detail={detail}
262
+ detailWidth={detailWidth}
263
+ height={height}
264
+ historyRange={historyRange}
265
+ loading={detailLoading}
266
+ onHistoryRangeChange={onHistoryRangeChange}
267
+ onSelectMarket={onSelectMarket}
268
+ selectedRow={selectedRow}
269
+ summary={summaryMetrics}
270
+ />
271
+ )}
281
272
 
282
- {detailTab === "book" && detail && (
273
+ {detailTab === "rules" && (
274
+ <PredictionMarketRulesView
275
+ detailWidth={detailTextWidth}
276
+ rules={detail?.rules ?? []}
277
+ />
278
+ )}
279
+ </ScrollBox>
280
+ ) : null}
281
+
282
+ {detailTab === "book" && (
283
+ detail ? (
283
284
  <PredictionMarketBookView
284
285
  detail={detail}
286
+ focused={focused}
285
287
  onPreviewOrder={onPreviewOrder}
288
+ width={detailWidth}
286
289
  />
287
- )}
288
-
289
- {detailTab === "trades" && (
290
- <PredictionMarketTradesView trades={detail?.trades ?? []} />
291
- )}
290
+ ) : (
291
+ <Box flexGrow={1} justifyContent="center">
292
+ <EmptyState
293
+ title={detailLoading ? "Loading order book." : "No order book."}
294
+ hint="This venue did not return current order book depth."
295
+ />
296
+ </Box>
297
+ )
298
+ )}
292
299
 
293
- {detailTab === "rules" && (
294
- <PredictionMarketRulesView
295
- detailWidth={detailTextWidth}
296
- rules={detail?.rules ?? []}
297
- />
298
- )}
299
- </ScrollBox>
300
+ {detailTab === "trades" && (
301
+ <PredictionMarketTradesView
302
+ focused={focused}
303
+ trades={detail?.trades ?? []}
304
+ width={detailWidth}
305
+ />
306
+ )}
300
307
  </>
301
308
  );
302
309
  }
@@ -1,6 +1,11 @@
1
1
  import { Box } from "../../../ui";
2
2
  import { colors } from "../../../theme/colors";
3
3
  import { ExternalLinkText } from "../../../components/ui/external-link";
4
+ import type {
5
+ PredictionListRow,
6
+ PredictionMarketDetail,
7
+ PredictionMarketSummary,
8
+ } from "../types";
4
9
 
5
10
  export function truncatePredictionText(
6
11
  value: string,
@@ -11,6 +16,20 @@ export function truncatePredictionText(
11
16
  return `${value.slice(0, maxLength - 3)}...`;
12
17
  }
13
18
 
19
+ export function resolvePredictionDetailTitle({
20
+ detail,
21
+ selectedRow,
22
+ selectedSummary,
23
+ }: {
24
+ detail: PredictionMarketDetail | null;
25
+ selectedRow: PredictionListRow | null;
26
+ selectedSummary: PredictionMarketSummary | null;
27
+ }): string | undefined {
28
+ if (!selectedSummary) return undefined;
29
+ const summary = detail?.summary ?? selectedSummary;
30
+ return selectedRow?.kind === "group" ? selectedRow.title : summary.title;
31
+ }
32
+
14
33
  export function SummaryLink({
15
34
  url,
16
35
  maxLength,
@@ -1,31 +1,76 @@
1
- import { Box, Text } from "../../../ui";
1
+ import { DataTableView, type DataTableColumn } from "../../../components";
2
2
  import { colors } from "../../../theme/colors";
3
- import { formatNumber, padTo } from "../../../utils/format";
3
+ import { formatNumber } from "../../../utils/format";
4
4
  import { formatPredictionProbability } from "../metrics";
5
5
  import type { PredictionTrade } from "../types";
6
6
 
7
+ type TradeColumnId = "time" | "side" | "outcome" | "price" | "size";
8
+ type TradeColumn = DataTableColumn & { id: TradeColumnId };
9
+
10
+ const TRADE_COLUMNS: TradeColumn[] = [
11
+ { id: "time", label: "TIME", width: 16 },
12
+ { id: "side", label: "SIDE", width: 6 },
13
+ { id: "outcome", label: "OUT", width: 4 },
14
+ { id: "price", label: "PRICE", width: 8, align: "right" },
15
+ { id: "size", label: "SIZE", width: 10, align: "right" },
16
+ ];
17
+
7
18
  export function PredictionMarketTradesView({
19
+ focused,
8
20
  trades,
21
+ width,
9
22
  }: {
23
+ focused: boolean;
10
24
  trades: PredictionTrade[];
25
+ width: number;
11
26
  }) {
12
27
  return (
13
- <Box flexDirection="column">
14
- <Box height={1}>
15
- <Text
16
- fg={colors.textDim}
17
- >{`${padTo("TIME", 16)} ${padTo("SIDE", 6)} ${padTo("OUT", 4)} ${padTo("PRICE", 8)} ${padTo("SIZE", 10)}`}</Text>
18
- </Box>
19
- {trades.slice(0, 30).map((trade) => (
20
- <Box key={trade.id} height={1}>
21
- <Text fg={trade.side === "buy" ? colors.positive : colors.negative}>
22
- {`${padTo(new Date(trade.timestamp).toLocaleTimeString("en-US", { hour12: false }), 16)} ${padTo(trade.side.toUpperCase(), 6)} ${padTo(trade.outcome.toUpperCase(), 4)} ${padTo(formatPredictionProbability(trade.price), 8)} ${padTo(formatNumber(trade.size, 0), 10, "right")}`}
23
- </Text>
24
- </Box>
25
- ))}
26
- {trades.length === 0 && (
27
- <Text fg={colors.textDim}>No recent trades.</Text>
28
- )}
29
- </Box>
28
+ <DataTableView<PredictionTrade, TradeColumn>
29
+ focused={focused}
30
+ keyboardNavigation={false}
31
+ rootWidth={width}
32
+ rootBackgroundColor={colors.panel}
33
+ selection={{ kind: "none" }}
34
+ columns={TRADE_COLUMNS}
35
+ items={trades.slice(0, 30)}
36
+ sortColumnId={null}
37
+ sortDirection="asc"
38
+ onHeaderClick={() => {}}
39
+ getItemKey={(trade) => trade.id}
40
+ renderCell={(trade, column) => {
41
+ const tradeColor = trade.side === "buy" ? colors.positive : colors.negative;
42
+ switch (column.id) {
43
+ case "time":
44
+ return {
45
+ text: new Date(trade.timestamp).toLocaleTimeString("en-US", {
46
+ hour12: false,
47
+ }),
48
+ color: colors.textDim,
49
+ };
50
+ case "side":
51
+ return {
52
+ text: trade.side.toUpperCase(),
53
+ color: tradeColor,
54
+ };
55
+ case "outcome":
56
+ return {
57
+ text: trade.outcome.toUpperCase(),
58
+ color: colors.text,
59
+ };
60
+ case "price":
61
+ return {
62
+ text: formatPredictionProbability(trade.price),
63
+ color: tradeColor,
64
+ };
65
+ case "size":
66
+ return {
67
+ text: formatNumber(trade.size, 0),
68
+ color: colors.textDim,
69
+ };
70
+ }
71
+ }}
72
+ emptyStateTitle="No recent trades."
73
+ emptyStateHint="This venue did not return recent prints."
74
+ />
30
75
  );
31
76
  }
@@ -48,4 +48,54 @@ describe("prediction markets detail views", () => {
48
48
  expect(frame).not.toContain("Kalshi");
49
49
  expect(frame).not.toContain("targets");
50
50
  });
51
+
52
+ test("keeps grouped outcomes in place while selected detail is loading", async () => {
53
+ testSetup = await testRender(<GroupedDetailHarness loading />, {
54
+ width: 64,
55
+ height: 24,
56
+ });
57
+ await flushFrames(testSetup);
58
+
59
+ const frame = testSetup.captureCharFrame();
60
+ expect(frame).toContain("Outcomes");
61
+ expect(frame).toContain("Above 4.25%");
62
+ expect(frame).toContain("Loading chart...");
63
+ expect(frame).not.toContain("No chart history.");
64
+ expect(frame).not.toContain("Loading market detail...");
65
+ });
66
+
67
+ test("renders book levels in the shared data table", async () => {
68
+ testSetup = await testRender(<GroupedDetailHarness detailTab="book" />, {
69
+ width: 64,
70
+ height: 24,
71
+ });
72
+ await flushFrames(testSetup);
73
+
74
+ const frame = testSetup.captureCharFrame();
75
+ expect(frame).toContain("OUT");
76
+ expect(frame).toContain("SIDE");
77
+ expect(frame).toContain("PRICE");
78
+ expect(frame).toContain("SIZE");
79
+ expect(frame).toContain("YES");
80
+ expect(frame).toContain("BID");
81
+ expect(frame).toContain("47c");
82
+ });
83
+
84
+ test("renders recent trades in the shared data table", async () => {
85
+ testSetup = await testRender(<GroupedDetailHarness detailTab="trades" />, {
86
+ width: 64,
87
+ height: 24,
88
+ });
89
+ await flushFrames(testSetup);
90
+
91
+ const frame = testSetup.captureCharFrame();
92
+ expect(frame).toContain("TIME");
93
+ expect(frame).toContain("SIDE");
94
+ expect(frame).toContain("OUT");
95
+ expect(frame).toContain("PRICE");
96
+ expect(frame).toContain("SIZE");
97
+ expect(frame).toContain("BUY");
98
+ expect(frame).toContain("YES");
99
+ expect(frame).toContain("48c");
100
+ });
51
101
  });
@@ -130,6 +130,32 @@ describe("prediction markets pane interactions", () => {
130
130
  expect(frame).not.toContain("Was there a typo in the url or port?");
131
131
  });
132
132
 
133
+ test("shows loading instead of an empty state while empty catalogs are pending", async () => {
134
+ attachPredictionMarketsPersistence(new MemoryPersistence());
135
+
136
+ const releaseFetches: Array<() => void> = [];
137
+ globalThis.fetch = (async (input: Request | string | URL) => {
138
+ await new Promise<void>((resolve) => {
139
+ releaseFetches.push(resolve);
140
+ });
141
+ const url = String(input);
142
+ if (url.includes("/trade-api/v2/events?")) {
143
+ return new Response(JSON.stringify({ events: [] }), { status: 200 });
144
+ }
145
+ return new Response(JSON.stringify([]), { status: 200 });
146
+ }) as unknown as typeof fetch;
147
+
148
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
149
+ await flushFrames(testSetup);
150
+
151
+ const frame = testSetup.captureCharFrame();
152
+ expect(frame).toContain("Loading markets...");
153
+ expect(frame).not.toContain("No markets matched.");
154
+
155
+ for (const release of releaseFetches) release();
156
+ await flushFrames(testSetup);
157
+ });
158
+
133
159
  test("selects a market on single click and opens detail on double click", async () => {
134
160
  installPredictionMarketMocks();
135
161
 
@@ -172,6 +198,23 @@ describe("prediction markets pane interactions", () => {
172
198
 
173
199
  frame = testSetup.captureCharFrame();
174
200
  expect(frame).toContain("Kalshi primary rule");
201
+ expect(frame).toContain("\u2190 Back Will the Fed cut rates?");
202
+ expect(frame.match(/Will the Fed cut rates\?/g) ?? []).toHaveLength(1);
203
+ expect(frame).not.toContain("[/]search");
204
+ expect(frame).not.toContain("[w]atch");
205
+ expect(frame).not.toContain("[1-4]browse");
206
+
207
+ const metricsHeader = frame
208
+ .split("\n")
209
+ .find((line) =>
210
+ line.includes("YES") &&
211
+ line.includes("NO") &&
212
+ line.includes("24H VOL"),
213
+ );
214
+ expect(metricsHeader).toContain("TOTAL VOL");
215
+ expect(metricsHeader).toContain("OI");
216
+ expect(metricsHeader).toContain("SPREAD");
217
+ expect(metricsHeader).toContain("LAST");
175
218
  });
176
219
 
177
220
  test("focuses the pane when a market row is clicked", async () => {
@@ -265,6 +308,38 @@ describe("prediction markets pane interactions", () => {
265
308
  expect(frame).not.toContain("\u2190 Back");
266
309
  });
267
310
 
311
+ test("moves focus between search and the market table with arrows", async () => {
312
+ installPredictionMarketMocks();
313
+
314
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
315
+ await flushFrames(testSetup);
316
+
317
+ await emitKeypress(testSetup, { name: "j", sequence: "j" });
318
+ await flushFrames(testSetup, 1);
319
+
320
+ await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
321
+ await flushFrames(testSetup);
322
+
323
+ let frame = testSetup.captureCharFrame();
324
+ expect(frame).toContain("/ search markets");
325
+
326
+ await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
327
+ await flushFrames(testSetup);
328
+
329
+ frame = testSetup.captureCharFrame();
330
+ expect(frame).toContain("? search markets");
331
+
332
+ await emitKeypress(testSetup, {
333
+ name: "down",
334
+ sequence: "\u001b[B",
335
+ targetEditable: true,
336
+ });
337
+ await flushFrames(testSetup);
338
+
339
+ frame = testSetup.captureCharFrame();
340
+ expect(frame).toContain("/ search markets");
341
+ });
342
+
268
343
  test("supports detail outcome navigation and escape return from the keyboard", async () => {
269
344
  attachPredictionMarketsPersistence(new MemoryPersistence());
270
345
 
@@ -1,14 +1,23 @@
1
1
  import { Box, Input, Text } from "../../ui";
2
2
  import { useCallback, useMemo, useRef } from "react";
3
- import { DataTableStackView, Tabs, usePaneFooter } from "../../components";
3
+ import {
4
+ DataTableStackView,
5
+ Spinner,
6
+ Tabs,
7
+ usePaneFooter,
8
+ type DataTableKeyEvent,
9
+ type DataTableRootKeyContext,
10
+ } from "../../components";
4
11
  import { createRowValueCache } from "../../components/ui/row-value-cache";
5
12
  import type { PaneProps } from "../../types/plugin";
6
13
  import { colors } from "../../theme/colors";
7
14
  import { PREDICTION_CATEGORY_OPTIONS } from "./categories";
8
15
  import { usePredictionMarketsController } from "./controller";
9
16
  import { PredictionMarketDetailPane } from "./detail/pane";
17
+ import { resolvePredictionDetailTitle } from "./detail/shared";
10
18
  import { getPredictionColumnValue } from "./metrics";
11
19
  import { BROWSE_TABS, VENUE_TABS } from "./navigation";
20
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../utils/search-focus-navigation";
12
21
  import type {
13
22
  PredictionBrowseTab,
14
23
  PredictionCategoryId,
@@ -66,33 +75,45 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
66
75
  controller.catalogStatus?.tone === "danger"
67
76
  ? colors.negative
68
77
  : colors.borderFocused;
69
- usePaneFooter("prediction-markets", () => ({
70
- info: [
71
- ...(controller.searchQuery.trim() ? [{ id: "search", parts: [{ text: `search: ${controller.searchQuery.trim()}`, tone: "value" as const }] }] : []),
72
- ...(controller.searchLoading ? [{ id: "search-loading", parts: [{ text: "searching", tone: "muted" as const }] }] : []),
73
- ...(controller.catalogStatus ? [{
74
- id: "catalog",
75
- parts: [{ text: controller.catalogStatus.message, tone: controller.catalogStatus.tone === "danger" ? "warning" as const : "muted" as const, color: catalogStatusColor }],
76
- }] : []),
77
- ],
78
- hints: [
79
- { id: "search", key: "/", label: "search", onPress: controller.actions.focusSearch },
80
- { id: "watch", key: "w", label: "atch", onPress: controller.selectedRow ? () => controller.actions.toggleWatchlist(controller.selectedRow!) : undefined, disabled: !controller.selectedRow },
81
- {
82
- id: "browse",
83
- key: "1-4",
84
- label: "browse",
85
- onPress: () => {
86
- const index = BROWSE_TABS.findIndex((tab) => tab.value === controller.browseTab);
87
- controller.actions.selectBrowseTab(BROWSE_TABS[(index + 1) % BROWSE_TABS.length]!.value as PredictionBrowseTab);
78
+ const rowsLoading =
79
+ controller.visibleRows.length === 0 &&
80
+ (controller.catalogLoadCount > 0 || controller.searchLoading);
81
+ const detailTitle = resolvePredictionDetailTitle({
82
+ detail: controller.detail,
83
+ selectedRow: controller.selectedRow,
84
+ selectedSummary: controller.selectedSummary,
85
+ });
86
+ usePaneFooter("prediction-markets", () => {
87
+ if (controller.detailOpen) return null;
88
+ return {
89
+ info: [
90
+ ...(controller.searchQuery.trim() ? [{ id: "search", parts: [{ text: `search: ${controller.searchQuery.trim()}`, tone: "value" as const }] }] : []),
91
+ ...(controller.searchLoading ? [{ id: "search-loading", parts: [{ text: "searching", tone: "muted" as const }] }] : []),
92
+ ...(controller.catalogStatus ? [{
93
+ id: "catalog",
94
+ parts: [{ text: controller.catalogStatus.message, tone: controller.catalogStatus.tone === "danger" ? "warning" as const : "muted" as const, color: catalogStatusColor }],
95
+ }] : []),
96
+ ],
97
+ hints: [
98
+ { id: "search", key: "/", label: "search", onPress: controller.actions.focusSearch },
99
+ { id: "watch", key: "w", label: "atch", onPress: controller.selectedRow ? () => controller.actions.toggleWatchlist(controller.selectedRow!) : undefined, disabled: !controller.selectedRow },
100
+ {
101
+ id: "browse",
102
+ key: "1-4",
103
+ label: "browse",
104
+ onPress: () => {
105
+ const index = BROWSE_TABS.findIndex((tab) => tab.value === controller.browseTab);
106
+ controller.actions.selectBrowseTab(BROWSE_TABS[(index + 1) % BROWSE_TABS.length]!.value as PredictionBrowseTab);
107
+ },
88
108
  },
89
- },
90
- ],
91
- }), [
109
+ ],
110
+ };
111
+ }, [
92
112
  catalogStatusColor,
93
113
  controller.browseTab,
94
114
  controller.catalogStatus?.message,
95
115
  controller.catalogStatus?.tone,
116
+ controller.detailOpen,
96
117
  controller.searchLoading,
97
118
  controller.searchQuery,
98
119
  controller.selectedRow,
@@ -217,6 +238,18 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
217
238
  watchlistedRowKeys,
218
239
  ]);
219
240
 
241
+ const handleRootKeyDown = useCallback((
242
+ event: DataTableKeyEvent,
243
+ context: DataTableRootKeyContext,
244
+ ) => {
245
+ if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
246
+ stopSearchFocusNavigation(event);
247
+ controller.actions.focusSearch();
248
+ return true;
249
+ }
250
+ return false;
251
+ }, [controller.actions.focusSearch]);
252
+
220
253
  const detailContent =
221
254
  controller.selectedSummary && controller.selectedRow ? (
222
255
  <Box
@@ -256,6 +289,7 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
256
289
  detailOpen={controller.detailOpen && !!controller.selectedSummary}
257
290
  onBack={controller.actions.closeDetail}
258
291
  detailContent={detailContent}
292
+ detailTitle={detailTitle}
259
293
  rootBefore={browseControls}
260
294
  rootWidth={width}
261
295
  rootHeight={height}
@@ -271,6 +305,7 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
271
305
  }}
272
306
  onActivate={(row) =>
273
307
  controller.actions.openSelectedRow(row.key)}
308
+ onRootKeyDown={handleRootKeyDown}
274
309
  columns={controller.visibleColumns}
275
310
  items={controller.visibleRows}
276
311
  sortColumnId={controller.sortPreference.columnId}
@@ -281,6 +316,19 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
281
316
  getItemKey={(row) => row.key}
282
317
  virtualize
283
318
  renderCell={renderCell}
319
+ emptyContent={
320
+ rowsLoading ? (
321
+ <Box width="100%" paddingX={1} paddingY={1}>
322
+ <Spinner
323
+ label={
324
+ controller.searchQuery.trim().length > 0
325
+ ? "Searching markets..."
326
+ : "Loading markets..."
327
+ }
328
+ />
329
+ </Box>
330
+ ) : undefined
331
+ }
284
332
  emptyStateTitle="No markets matched."
285
333
  emptyStateHint="Change the venue, browse tab, or search query."
286
334
  />