gloomberb 0.3.7 → 0.4.0

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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,4 +1,4 @@
1
- import { useRef, type RefObject } from "react";
1
+ import { memo, useRef, type RefObject } from "react";
2
2
  import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { EmptyState } from "./ui";
4
4
  import { colors, hoverBg } from "../theme/colors";
@@ -13,6 +13,11 @@ export interface TickerTableCell {
13
13
  }
14
14
 
15
15
  export type QuoteFlashDirection = "up" | "down" | "flat";
16
+ type ResolveTickerTableCell = (
17
+ column: ColumnConfig,
18
+ ticker: TickerRecord,
19
+ financials: TickerFinancials | undefined,
20
+ ) => TickerTableCell;
16
21
 
17
22
  const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
18
23
  "price",
@@ -39,6 +44,153 @@ function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: s
39
44
  }
40
45
  }
41
46
 
47
+ interface TickerListTableProps {
48
+ columns: ColumnConfig[];
49
+ tickers: TickerRecord[];
50
+ cursorSymbol: string | null;
51
+ hoveredIdx: number | null;
52
+ setHoveredIdx: (index: number | null) => void;
53
+ setCursorSymbol: (symbol: string) => void;
54
+ resolveCell: ResolveTickerTableCell;
55
+ financialsMap: Map<string, TickerFinancials>;
56
+ headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
57
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
58
+ syncHeaderScroll?: () => void;
59
+ onBodyScrollActivity?: () => void;
60
+ flashSymbols?: Map<string, QuoteFlashDirection>;
61
+ sortColumnId?: string | null;
62
+ sortDirection?: "asc" | "desc";
63
+ onHeaderClick?: (columnId: string) => void;
64
+ onRowActivate?: (ticker: TickerRecord) => void;
65
+ emptyTitle?: string;
66
+ emptyHint?: string;
67
+ }
68
+
69
+ const TickerListHeader = memo(function TickerListHeader({
70
+ columns,
71
+ headerScrollRef,
72
+ sortColumnId,
73
+ sortDirection,
74
+ onHeaderClick,
75
+ }: {
76
+ columns: ColumnConfig[];
77
+ headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
78
+ sortColumnId?: string | null;
79
+ sortDirection?: "asc" | "desc";
80
+ onHeaderClick?: (columnId: string) => void;
81
+ }) {
82
+ return (
83
+ <scrollbox
84
+ ref={headerScrollRef}
85
+ height={1}
86
+ scrollX
87
+ focusable={false}
88
+ >
89
+ <box flexDirection="row" height={1} paddingX={1}>
90
+ {columns.map((column) => {
91
+ const isSorted = sortColumnId === column.id;
92
+ const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
93
+ const labelText = column.label + indicator;
94
+ return (
95
+ <box
96
+ key={column.id}
97
+ width={column.width + 1}
98
+ onMouseDown={onHeaderClick ? (event) => {
99
+ event.preventDefault();
100
+ onHeaderClick(column.id);
101
+ } : undefined}
102
+ >
103
+ <text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
104
+ {padTo(labelText, column.width, column.align)}
105
+ </text>
106
+ </box>
107
+ );
108
+ })}
109
+ </box>
110
+ </scrollbox>
111
+ );
112
+ });
113
+
114
+ const TickerListRow = memo(function TickerListRow({
115
+ columns,
116
+ ticker,
117
+ index,
118
+ isSelected,
119
+ isHovered,
120
+ financials,
121
+ flashDirection,
122
+ setHoveredIdx,
123
+ setCursorSymbol,
124
+ resolveCell,
125
+ onRowActivate,
126
+ }: {
127
+ columns: ColumnConfig[];
128
+ ticker: TickerRecord;
129
+ index: number;
130
+ isSelected: boolean;
131
+ isHovered: boolean;
132
+ financials: TickerFinancials | undefined;
133
+ flashDirection: QuoteFlashDirection | undefined;
134
+ setHoveredIdx: (index: number | null) => void;
135
+ setCursorSymbol: (symbol: string) => void;
136
+ resolveCell: ResolveTickerTableCell;
137
+ onRowActivate?: (ticker: TickerRecord) => void;
138
+ }) {
139
+ const lastActivatedAtRef = useRef<number | null>(null);
140
+ const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
141
+
142
+ return (
143
+ <box
144
+ flexDirection="row"
145
+ height={1}
146
+ paddingX={1}
147
+ backgroundColor={rowBg}
148
+ onMouseMove={() => setHoveredIdx(index)}
149
+ onMouseDown={(event) => {
150
+ event.preventDefault();
151
+ setCursorSymbol(ticker.metadata.ticker);
152
+ if (!onRowActivate) return;
153
+ const now = Date.now();
154
+ if (lastActivatedAtRef.current != null && now - lastActivatedAtRef.current <= 350) {
155
+ lastActivatedAtRef.current = null;
156
+ onRowActivate(ticker);
157
+ return;
158
+ }
159
+ lastActivatedAtRef.current = now;
160
+ }}
161
+ >
162
+ {columns.map((column) => {
163
+ const { text, color } = resolveCell(column, ticker, financials);
164
+ const baseFg = color || (isSelected ? colors.selectedText : colors.text);
165
+ const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
166
+ const cellFg = shouldFlash
167
+ ? resolveQuoteFlashColor(flashDirection, baseFg)
168
+ : baseFg;
169
+
170
+ return (
171
+ <box key={column.id} width={column.width + 1}>
172
+ <text fg={cellFg}>
173
+ {padTo(text, column.width, column.align)}
174
+ </text>
175
+ </box>
176
+ );
177
+ })}
178
+ </box>
179
+ );
180
+ }, (previous, next) => (
181
+ previous.columns === next.columns
182
+ && previous.ticker === next.ticker
183
+ && previous.index === next.index
184
+ && previous.isSelected === next.isSelected
185
+ && previous.isHovered === next.isHovered
186
+ && previous.financials === next.financials
187
+ && previous.flashDirection === next.flashDirection
188
+ && previous.setHoveredIdx === next.setHoveredIdx
189
+ && previous.setCursorSymbol === next.setCursorSymbol
190
+ && previous.resolveCell === next.resolveCell
191
+ && previous.onRowActivate === next.onRowActivate
192
+ ));
193
+
42
194
  export function TickerListTable({
43
195
  columns,
44
196
  tickers,
@@ -59,60 +211,18 @@ export function TickerListTable({
59
211
  onRowActivate,
60
212
  emptyTitle = "No tickers.",
61
213
  emptyHint = "Press Ctrl+P to add one.",
62
- }: {
63
- columns: ColumnConfig[];
64
- tickers: TickerRecord[];
65
- cursorSymbol: string | null;
66
- hoveredIdx: number | null;
67
- setHoveredIdx: (index: number | null) => void;
68
- setCursorSymbol: (symbol: string) => void;
69
- resolveCell: (column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => TickerTableCell;
70
- financialsMap: Map<string, TickerFinancials>;
71
- headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
72
- scrollRef?: RefObject<ScrollBoxRenderable | null>;
73
- syncHeaderScroll?: () => void;
74
- onBodyScrollActivity?: () => void;
75
- flashSymbols?: Map<string, QuoteFlashDirection>;
76
- sortColumnId?: string | null;
77
- sortDirection?: "asc" | "desc";
78
- onHeaderClick?: (columnId: string) => void;
79
- onRowActivate?: (ticker: TickerRecord) => void;
80
- emptyTitle?: string;
81
- emptyHint?: string;
82
- }) {
214
+ }: TickerListTableProps) {
83
215
  const safeFlashSymbols = flashSymbols ?? new Map<string, QuoteFlashDirection>();
84
- const lastActivatedRef = useRef<{ symbol: string; at: number } | null>(null);
85
216
 
86
217
  return (
87
218
  <>
88
- <scrollbox
89
- ref={headerScrollRef}
90
- height={1}
91
- scrollX
92
- focusable={false}
93
- >
94
- <box flexDirection="row" height={1} paddingX={1}>
95
- {columns.map((column) => {
96
- const isSorted = sortColumnId === column.id;
97
- const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
98
- const labelText = column.label + indicator;
99
- return (
100
- <box
101
- key={column.id}
102
- width={column.width + 1}
103
- onMouseDown={onHeaderClick ? (event) => {
104
- event.preventDefault();
105
- onHeaderClick(column.id);
106
- } : undefined}
107
- >
108
- <text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
109
- {padTo(labelText, column.width, column.align)}
110
- </text>
111
- </box>
112
- );
113
- })}
114
- </box>
115
- </scrollbox>
219
+ <TickerListHeader
220
+ columns={columns}
221
+ headerScrollRef={headerScrollRef}
222
+ sortColumnId={sortColumnId}
223
+ sortDirection={sortDirection}
224
+ onHeaderClick={onHeaderClick}
225
+ />
116
226
 
117
227
  <scrollbox
118
228
  ref={scrollRef}
@@ -131,54 +241,21 @@ export function TickerListTable({
131
241
  </box>
132
242
  ) : (
133
243
  tickers.map((ticker, index) => {
134
- const isSelected = ticker.metadata.ticker === cursorSymbol;
135
- const isHovered = index === hoveredIdx && !isSelected;
136
- const financials = financialsMap.get(ticker.metadata.ticker);
137
- const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
138
- const flashDirection = safeFlashSymbols.get(ticker.metadata.ticker);
139
-
140
244
  return (
141
- <box
245
+ <TickerListRow
142
246
  key={ticker.metadata.ticker}
143
- flexDirection="row"
144
- height={1}
145
- paddingX={1}
146
- backgroundColor={rowBg}
147
- onMouseMove={() => setHoveredIdx(index)}
148
- onMouseDown={(event) => {
149
- event.preventDefault();
150
- setCursorSymbol(ticker.metadata.ticker);
151
- if (!onRowActivate) return;
152
- const now = Date.now();
153
- const last = lastActivatedRef.current;
154
- if (last?.symbol === ticker.metadata.ticker && now - last.at <= 350) {
155
- lastActivatedRef.current = null;
156
- onRowActivate(ticker);
157
- return;
158
- }
159
- lastActivatedRef.current = {
160
- symbol: ticker.metadata.ticker,
161
- at: now,
162
- };
163
- }}
164
- >
165
- {columns.map((column) => {
166
- const { text, color } = resolveCell(column, ticker, financials);
167
- const baseFg = color || (isSelected ? colors.selectedText : colors.text);
168
- const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
169
- const cellFg = shouldFlash
170
- ? resolveQuoteFlashColor(flashDirection, baseFg)
171
- : baseFg;
172
-
173
- return (
174
- <box key={column.id} width={column.width + 1}>
175
- <text fg={cellFg}>
176
- {padTo(text, column.width, column.align)}
177
- </text>
178
- </box>
179
- );
180
- })}
181
- </box>
247
+ columns={columns}
248
+ ticker={ticker}
249
+ index={index}
250
+ isSelected={ticker.metadata.ticker === cursorSymbol}
251
+ isHovered={index === hoveredIdx && ticker.metadata.ticker !== cursorSymbol}
252
+ financials={financialsMap.get(ticker.metadata.ticker)}
253
+ flashDirection={safeFlashSymbols.get(ticker.metadata.ticker)}
254
+ setHoveredIdx={setHoveredIdx}
255
+ setCursorSymbol={setCursorSymbol}
256
+ resolveCell={resolveCell}
257
+ onRowActivate={onRowActivate}
258
+ />
182
259
  );
183
260
  })
184
261
  )}
@@ -3,6 +3,7 @@ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { colors, hoverBg } from "../../theme/colors";
4
4
  import type { ColumnConfig } from "../../types/config";
5
5
  import { padTo } from "../../utils/format";
6
+ import { useDoubleClickActivation } from "../use-double-click-activation";
6
7
  import { EmptyState } from "./status";
7
8
 
8
9
  export type DataTableColumn = Pick<
@@ -34,6 +35,7 @@ export interface DataTableProps<
34
35
  getItemKey: (item: T, index: number) => string;
35
36
  isSelected: (item: T, index: number) => boolean;
36
37
  onSelect: (item: T, index: number) => void;
38
+ onActivate?: (item: T, index: number) => void;
37
39
  renderCell: (
38
40
  item: T,
39
41
  column: C,
@@ -46,6 +48,11 @@ export interface DataTableProps<
46
48
  overscan?: number;
47
49
  }
48
50
 
51
+ interface DataTableRowPointerTarget<T> {
52
+ item: T;
53
+ index: number;
54
+ }
55
+
49
56
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
50
57
  columns,
51
58
  items,
@@ -61,6 +68,7 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
61
68
  getItemKey,
62
69
  isSelected,
63
70
  onSelect,
71
+ onActivate,
64
72
  renderCell,
65
73
  emptyStateTitle,
66
74
  emptyStateHint,
@@ -80,6 +88,17 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
80
88
  () => items.slice(startIndex, endIndex),
81
89
  [endIndex, items, scrollVersion, startIndex],
82
90
  );
91
+ const handleRowMouseDown =
92
+ useDoubleClickActivation<DataTableRowPointerTarget<T>>({
93
+ onSelect: ({ item, index }) => {
94
+ onSelect(item, index);
95
+ },
96
+ onActivate: onActivate
97
+ ? ({ item, index }) => {
98
+ onActivate(item, index);
99
+ }
100
+ : undefined,
101
+ });
83
102
 
84
103
  const handleBodyScrollActivity = () => {
85
104
  if (virtualize) {
@@ -163,7 +182,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
163
182
  onMouseMove={() => setHoveredIdx(index)}
164
183
  onMouseDown={(event) => {
165
184
  event.preventDefault();
166
- onSelect(item, index);
185
+ handleRowMouseDown(getItemKey(item, index), {
186
+ item,
187
+ index,
188
+ });
167
189
  }}
168
190
  >
169
191
  {columns.map((column) => {
@@ -182,7 +204,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
182
204
  }
183
205
  event.preventDefault();
184
206
  event.stopPropagation?.();
185
- onSelect(item, index);
207
+ handleRowMouseDown(getItemKey(item, index), {
208
+ item,
209
+ index,
210
+ });
186
211
  }}
187
212
  >
188
213
  <text
@@ -43,6 +43,7 @@ export { Section, FieldRow, DialogFrame } from "./frame";
43
43
  export type { SectionProps, FieldRowProps, DialogFrameProps } from "./frame";
44
44
 
45
45
  export { ExternalLink, openUrl } from "./external-link";
46
+ export { PageStackView } from "./page-stack-view";
46
47
 
47
48
  export { Spinner, ProgressBar, SkeletonRow, LoadingBlock } from "./loading";
48
49
  export type {
@@ -0,0 +1,60 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import type { ReactNode } from "react";
3
+ import { colors } from "../../theme/colors";
4
+ import { isBackNavigationKey } from "../../utils/back-navigation";
5
+
6
+ interface PageStackViewProps {
7
+ focused: boolean;
8
+ detailOpen: boolean;
9
+ onBack: () => void;
10
+ rootContent: ReactNode;
11
+ detailContent: ReactNode;
12
+ backLabel?: string;
13
+ backHint?: string;
14
+ }
15
+
16
+ export function PageStackView({
17
+ focused,
18
+ detailOpen,
19
+ onBack,
20
+ rootContent,
21
+ detailContent,
22
+ backLabel = "Back",
23
+ backHint,
24
+ }: PageStackViewProps) {
25
+ useKeyboard((event) => {
26
+ if (!focused || !detailOpen || !isBackNavigationKey(event)) return;
27
+ event.stopPropagation?.();
28
+ event.preventDefault?.();
29
+ onBack();
30
+ });
31
+
32
+ if (!detailOpen) {
33
+ return (
34
+ <box flexDirection="column" flexGrow={1}>
35
+ {rootContent}
36
+ </box>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <box flexDirection="column" flexGrow={1}>
42
+ <box height={1} flexDirection="row">
43
+ <box
44
+ onMouseDown={(event) => {
45
+ event.preventDefault?.();
46
+ event.stopPropagation?.();
47
+ onBack();
48
+ }}
49
+ >
50
+ <text fg={colors.textBright}>{`<- ${backLabel}`}</text>
51
+ </box>
52
+ <box flexGrow={1} />
53
+ {backHint ? <text fg={colors.textMuted}>{backHint}</text> : null}
54
+ </box>
55
+ <box flexDirection="column" flexGrow={1}>
56
+ {detailContent}
57
+ </box>
58
+ </box>
59
+ );
60
+ }
@@ -0,0 +1,31 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ interface LastClickState {
4
+ targetKey: string;
5
+ }
6
+
7
+ export function useDoubleClickActivation<T>({
8
+ onSelect,
9
+ onActivate,
10
+ }: {
11
+ onSelect?: (value: T) => void;
12
+ onActivate?: (value: T) => void;
13
+ }) {
14
+ const lastClickRef = useRef<LastClickState | null>(null);
15
+
16
+ return useCallback((targetKey: string, value: T) => {
17
+ const lastClick = lastClickRef.current;
18
+
19
+ onSelect?.(value);
20
+
21
+ if (lastClick && lastClick.targetKey === targetKey) {
22
+ lastClickRef.current = null;
23
+ onActivate?.(value);
24
+ return;
25
+ }
26
+
27
+ lastClickRef.current = {
28
+ targetKey,
29
+ };
30
+ }, [onActivate, onSelect]);
31
+ }
package/src/index.tsx CHANGED
@@ -4,10 +4,9 @@ import { App } from "./app";
4
4
  import { getDataDir, initDataDir } from "./data/config-store";
5
5
  import { join } from "path";
6
6
  import { existsSync, mkdirSync } from "fs";
7
- import { runCli } from "./cli/index";
7
+ import { dispatchCli } from "./cli/index";
8
8
  import { loadExternalPlugins } from "./plugins/loader";
9
9
  import { debugLog } from "./utils/debug-log";
10
- import { parsePredictionLaunchArgs } from "./plugins/prediction-markets/launch";
11
10
 
12
11
  async function main() {
13
12
  // Intercept console.log/warn/error to capture in debug log
@@ -18,10 +17,14 @@ async function main() {
18
17
 
19
18
  // Handle CLI subcommands (install, remove, update, plugins)
20
19
  const cliArgs = process.argv.slice(2);
21
- const predictionLaunchIntent = parsePredictionLaunchArgs(cliArgs);
22
- if (cliArgs.length > 0 && !predictionLaunchIntent) {
23
- const handled = await runCli(cliArgs);
24
- if (handled) return;
20
+ const externalPlugins = await loadExternalPlugins();
21
+ let cliLaunchRequest = null;
22
+ if (cliArgs.length > 0) {
23
+ const dispatchResult = await dispatchCli(cliArgs, { externalPlugins });
24
+ if (dispatchResult.kind === "handled") return;
25
+ if (dispatchResult.kind === "launch-ui") {
26
+ cliLaunchRequest = dispatchResult.request;
27
+ }
25
28
  }
26
29
 
27
30
  // Determine data directory
@@ -40,9 +43,6 @@ async function main() {
40
43
  // Load or create config
41
44
  const config = await initDataDir(dataDir);
42
45
 
43
- // Load external plugins from ~/.gloomberb/plugins/
44
- const externalPlugins = await loadExternalPlugins();
45
-
46
46
  // Create renderer
47
47
  const renderer = await createCliRenderer({
48
48
  exitOnCtrlC: true,
@@ -56,7 +56,7 @@ async function main() {
56
56
  config={config}
57
57
  renderer={renderer}
58
58
  externalPlugins={externalPlugins}
59
- predictionLaunchIntent={predictionLaunchIntent}
59
+ cliLaunchRequest={cliLaunchRequest}
60
60
  />
61
61
  );
62
62
  }
@@ -80,8 +80,8 @@ describe("MarketDataCoordinator", () => {
80
80
  const coordinator = new MarketDataCoordinator(provider);
81
81
  const request = {
82
82
  instrument: { symbol: "AAPL", exchange: "NASDAQ" },
83
- range: "1Y" as const,
84
- granularity: "daily" as const,
83
+ bufferRange: "1Y" as const,
84
+ granularity: "range" as const,
85
85
  };
86
86
 
87
87
  const first = await coordinator.loadChart(request);
@@ -103,8 +103,8 @@ describe("MarketDataCoordinator", () => {
103
103
  const coordinator = new MarketDataCoordinator(provider);
104
104
  const request = {
105
105
  instrument: { symbol: "AAPL", exchange: "NASDAQ" },
106
- range: "1Y" as const,
107
- granularity: "daily" as const,
106
+ bufferRange: "1Y" as const,
107
+ granularity: "range" as const,
108
108
  };
109
109
 
110
110
  const entry = await coordinator.loadChart(request);
@@ -142,6 +142,30 @@ describe("MarketDataCoordinator", () => {
142
142
  expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(412.5);
143
143
  });
144
144
 
145
+ it("routes manual-resolution chart requests through the resolution-aware provider path", async () => {
146
+ let requested: { range: string; resolution: string } | null = null;
147
+ const provider = createProvider({
148
+ getPriceHistoryForResolution: async (_symbol, _exchange, range, resolution) => {
149
+ requested = { range, resolution };
150
+ return [
151
+ { date: new Date("2024-01-01"), close: 100 },
152
+ { date: new Date("2024-01-02"), close: 101 },
153
+ ];
154
+ },
155
+ });
156
+ const coordinator = new MarketDataCoordinator(provider);
157
+
158
+ const entry = await coordinator.loadChart({
159
+ instrument: { symbol: "AAPL", exchange: "NASDAQ" },
160
+ bufferRange: "1Y",
161
+ granularity: "resolution",
162
+ resolution: "1d",
163
+ });
164
+
165
+ expect(requested).toEqual({ range: "1Y", resolution: "1d" });
166
+ expect(entry.data?.length).toBe(2);
167
+ });
168
+
145
169
  it("projects live quote updates into premarket display fields when the stream lacks explicit ext-hours fields", async () => {
146
170
  let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
147
171
  const provider = createProvider({
@@ -192,6 +216,7 @@ describe("MarketDataCoordinator", () => {
192
216
  change: 2.5,
193
217
  changePercent: 2.48,
194
218
  lastUpdated: Date.now(),
219
+ dataSource: "live",
195
220
  },
196
221
  );
197
222
 
@@ -287,6 +312,69 @@ describe("MarketDataCoordinator", () => {
287
312
  expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(0.245);
288
313
  });
289
314
 
315
+ it("keeps the fresh snapshot quote when a stale cloud stream update arrives later", async () => {
316
+ const fixedNow = Date.parse("2026-04-08T10:30:00Z");
317
+ const realDateNow = Date.now;
318
+ Date.now = () => fixedNow;
319
+ try {
320
+ let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
321
+ const provider = createProvider({
322
+ getTickerFinancials: async () => ({
323
+ quote: {
324
+ symbol: "HY9H",
325
+ providerId: "yahoo",
326
+ dataSource: "yahoo",
327
+ price: 598,
328
+ currency: "EUR",
329
+ change: 6,
330
+ changePercent: 1.01,
331
+ lastUpdated: Date.parse("2026-04-08T10:25:00Z"),
332
+ listingExchangeName: "FWB2",
333
+ marketState: "REGULAR",
334
+ sessionConfidence: "derived",
335
+ },
336
+ annualStatements: [],
337
+ quarterlyStatements: [],
338
+ priceHistory: [],
339
+ }),
340
+ subscribeQuotes: (_targets, onQuote) => {
341
+ streamed = onQuote as typeof streamed;
342
+ return () => {};
343
+ },
344
+ });
345
+ const coordinator = new MarketDataCoordinator(provider);
346
+ const instrument = { symbol: "HY9H", exchange: "FWB2" };
347
+
348
+ await coordinator.loadSnapshot(instrument);
349
+ coordinator.subscribeQuotes([{ instrument }]);
350
+ const onStreamed = streamed;
351
+ if (!onStreamed) throw new Error("expected streaming callback");
352
+ onStreamed(
353
+ { symbol: "HY9H", exchange: "FWB2" },
354
+ {
355
+ symbol: "HY9H",
356
+ providerId: "gloomberb-cloud",
357
+ dataSource: "delayed",
358
+ price: 528,
359
+ currency: "EUR",
360
+ change: 4,
361
+ changePercent: 0.76,
362
+ lastUpdated: Date.parse("2026-04-07T17:55:00Z"),
363
+ listingExchangeName: "FWB2",
364
+ marketState: "REGULAR",
365
+ sessionConfidence: "explicit",
366
+ },
367
+ );
368
+
369
+ expect(coordinator.getQuoteEntry(instrument).data).toBeNull();
370
+ expect(coordinator.getQuoteEntry(instrument).lastGoodData?.price).toBe(598);
371
+ expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.price).toBe(598);
372
+ expect(coordinator.getTickerFinancialsSync(instrument)?.quote?.providerId).toBe("yahoo");
373
+ } finally {
374
+ Date.now = realDateNow;
375
+ }
376
+ });
377
+
290
378
  it("hydrates ticker financials synchronously from primed cached data", () => {
291
379
  const coordinator = new MarketDataCoordinator(createProvider());
292
380
  const instrument = { symbol: "AAPL", exchange: "NASDAQ" };