gloomberb 0.9.3 → 0.9.5

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 (176) hide show
  1. package/README.md +1 -0
  2. package/package.json +3 -2
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/runtime/desktop-menu.ts +5 -1
  6. package/src/app/runtime/ticker-refresh.ts +4 -1
  7. package/src/app.tsx +10 -1
  8. package/src/brokers/account-cache.ts +1 -1
  9. package/src/brokers/sync-broker-instance.test.ts +107 -0
  10. package/src/brokers/sync-broker-instance.ts +22 -10
  11. package/src/cli/desktop-pane-shot.ts +2 -1
  12. package/src/cli/entry.ts +8 -1
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/cli/pane-functions/screenshot.ts +13 -1
  15. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  16. package/src/components/chart/bar-chart-renderer.ts +226 -27
  17. package/src/components/chart/core/pointer.ts +32 -6
  18. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  19. package/src/components/chart/native/bitmap-support.ts +55 -0
  20. package/src/components/chart/native/kitty/index.test.ts +47 -0
  21. package/src/components/chart/native/surface/visibility.ts +8 -1
  22. package/src/components/chart/price-axis-labels.tsx +3 -1
  23. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  24. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  25. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  26. package/src/components/chart/static/chart/bitmap.ts +16 -9
  27. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  28. package/src/components/chart/static/chart/surface.tsx +167 -24
  29. package/src/components/chart/stock-chart/index.test.ts +25 -0
  30. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  31. package/src/components/command-bar/list/view.tsx +1 -1
  32. package/src/components/command-bar/theme-picker.tsx +1 -1
  33. package/src/components/data-table/view.tsx +0 -12
  34. package/src/components/layout/detached-pane-shell.tsx +6 -3
  35. package/src/components/layout/floating-pane.tsx +6 -5
  36. package/src/components/layout/pane/content.tsx +20 -8
  37. package/src/components/layout/pane/footer/registration.tsx +6 -2
  38. package/src/components/layout/pane/frame.tsx +4 -4
  39. package/src/components/layout/pane/header.tsx +45 -4
  40. package/src/components/layout/pane/index.tsx +6 -4
  41. package/src/components/layout/pane/sizing.test.ts +43 -0
  42. package/src/components/layout/pane/sizing.ts +13 -4
  43. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  44. package/src/components/layout/shell/cursor-occlusion.ts +204 -0
  45. package/src/components/layout/shell/drag/runtime.ts +9 -2
  46. package/src/components/layout/shell/index.test.tsx +92 -2
  47. package/src/components/layout/shell/index.tsx +45 -3
  48. package/src/components/layout/shell/layout-state.test.ts +33 -0
  49. package/src/components/layout/shell/layout-state.ts +4 -9
  50. package/src/components/layout/shell/pane/layers.tsx +10 -4
  51. package/src/components/layout/shell/visible-layout.ts +17 -0
  52. package/src/components/layout/status-bar.test.tsx +6 -1
  53. package/src/components/layout/status-bar.tsx +10 -6
  54. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  55. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  56. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  57. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  58. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  59. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  60. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  61. package/src/components/price-performance.tsx +1 -1
  62. package/src/components/price-sparkline/view.tsx +15 -14
  63. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  64. package/src/components/table-view-shared.tsx +12 -14
  65. package/src/components/ticker/list-table-view.tsx +1 -7
  66. package/src/components/ticker/list-table.test.tsx +0 -4
  67. package/src/components/ui/data-table/opentui/index.tsx +29 -22
  68. package/src/components/ui/data-table/types.ts +10 -4
  69. package/src/components/ui/fields.tsx +10 -3
  70. package/src/components/ui/list-view.tsx +1 -1
  71. package/src/components/ui/page-stack-view.tsx +6 -2
  72. package/src/components/ui/table-layout.test.ts +25 -0
  73. package/src/components/ui/table-layout.ts +22 -6
  74. package/src/components/ui/tabs.tsx +27 -12
  75. package/src/components/ui/ui.test.tsx +87 -9
  76. package/src/core/state/app/layout-reducer.ts +2 -2
  77. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  78. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  79. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  80. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  81. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  82. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  83. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  84. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  85. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  86. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  87. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  88. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  89. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  90. package/src/plugins/builtin/debug/index.tsx +3 -4
  91. package/src/plugins/builtin/help/components.tsx +1 -1
  92. package/src/plugins/builtin/holders/treemap.tsx +1 -1
  93. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  94. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  95. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  96. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  97. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  98. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  99. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  100. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  101. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  102. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  103. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  104. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  105. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  106. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  107. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  108. package/src/plugins/builtin/options/table.ts +5 -5
  109. package/src/plugins/builtin/options/view.test.tsx +141 -4
  110. package/src/plugins/builtin/options/view.tsx +76 -15
  111. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  112. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  113. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  114. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  115. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  116. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  117. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  118. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  119. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  120. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  121. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  122. package/src/plugins/catalog-ui.ts +2 -0
  123. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  124. package/src/plugins/ibkr/config.test.ts +31 -2
  125. package/src/plugins/ibkr/config.ts +9 -2
  126. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  127. package/src/plugins/ibkr/flex/index.ts +30 -0
  128. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  129. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  130. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  131. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  132. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  133. package/src/plugins/pane-manager/gridlock.ts +13 -5
  134. package/src/plugins/pane-manager/layout-state.ts +25 -0
  135. package/src/plugins/pane-manager.test.ts +33 -0
  136. package/src/plugins/pane-manager.ts +6 -1
  137. package/src/plugins/prediction-markets/chart.tsx +10 -4
  138. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  139. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  140. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  141. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  142. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  143. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  144. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  145. package/src/plugins/prediction-markets/pane.tsx +50 -22
  146. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  147. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  148. package/src/renderers/electrobun/view/data-table/index.tsx +19 -12
  149. package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
  150. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  151. package/src/renderers/electrobun/view/host/box.tsx +6 -1
  152. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  153. package/src/renderers/electrobun/view/host/style.ts +1 -0
  154. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  155. package/src/renderers/electrobun/view/styles.css +18 -0
  156. package/src/renderers/opentui/host.tsx +17 -1
  157. package/src/renderers/opentui/native-smoke.ts +1 -0
  158. package/src/renderers/opentui/ui-host.tsx +57 -25
  159. package/src/sources/provider-router/batches.ts +16 -2
  160. package/src/sources/provider-router/financial-routes.ts +5 -0
  161. package/src/sources/provider-router/financials.ts +11 -23
  162. package/src/sources/provider-router/index.test.ts +189 -0
  163. package/src/sources/provider-router/primary.ts +3 -2
  164. package/src/sources/sec-edgar.test.ts +83 -0
  165. package/src/sources/sec-edgar.ts +231 -0
  166. package/src/sources/yahoo-finance.test.ts +51 -0
  167. package/src/sources/yahoo-finance.ts +49 -1
  168. package/src/state/app/context/index.test.tsx +5 -8
  169. package/src/types/broker.ts +1 -0
  170. package/src/types/config.ts +10 -0
  171. package/src/types/trading.ts +2 -0
  172. package/src/ui/host.tsx +16 -0
  173. package/src/ui/index.tsx +2 -0
  174. package/src/utils/financial-statements.ts +23 -0
  175. package/src/version.ts +1 -1
  176. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -0,0 +1,375 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui";
3
+ import type { KeyEventLike } from "../../../react/input";
4
+ import { colors } from "../../../theme/colors";
5
+ import { NumberField } from "../../../components/ui";
6
+ import { formatCurrency, formatNumber, formatPercentRaw } from "../../../utils/format";
7
+ import { isPlainKey } from "../../../utils/keyboard";
8
+ import type { SensitivityGrid } from "./model";
9
+ import type { InlineField } from "./fields";
10
+ export type { InlineField } from "./fields";
11
+
12
+ export function truncateText(value: string, maxWidth: number): string {
13
+ if (maxWidth <= 0) return "";
14
+ if (value.length <= maxWidth) return value;
15
+ if (maxWidth <= 1) return value.slice(0, maxWidth);
16
+ return `${value.slice(0, maxWidth - 1)}…`;
17
+ }
18
+
19
+ export function formatPct(value: number, decimals = 1): string {
20
+ return `${formatNumber(value * 100, decimals)}%`;
21
+ }
22
+
23
+ export function formatSignedPct(value: number): string {
24
+ return formatPercentRaw(value * 100);
25
+ }
26
+
27
+ export function isPlainShortcut(event: KeyEventLike, ...names: string[]): boolean {
28
+ if (isPlainKey(event, ...names)) return true;
29
+ const key = event.key?.toLowerCase();
30
+ return !event.ctrl && !event.meta && !event.super && !event.alt && names.includes(key);
31
+ }
32
+
33
+ function parsePromptNumber(value: string): number | null {
34
+ const parsed = Number(value);
35
+ return Number.isFinite(parsed) ? parsed : null;
36
+ }
37
+
38
+ function formatInputNumber(value: number, percent = false): string {
39
+ const scaled = percent ? value * 100 : value;
40
+ const decimals = percent
41
+ ? Math.abs(scaled) >= 10 ? 1 : 2
42
+ : Math.abs(scaled) >= 100 ? 0 : 2;
43
+ return formatNumber(scaled, decimals).replace(/,/g, "");
44
+ }
45
+
46
+ export function InlineFieldView({
47
+ field,
48
+ active,
49
+ width,
50
+ focused,
51
+ onFocus,
52
+ }: {
53
+ field: InlineField;
54
+ active: boolean;
55
+ width: number;
56
+ focused: boolean;
57
+ onFocus: () => void;
58
+ }) {
59
+ const labelWidth = Math.min(12, Math.max(7, Math.floor(width * 0.38)));
60
+ const suffixWidth = field.suffix ? field.suffix.length + 1 : field.percent ? 2 : 0;
61
+ const valueWidth = Math.max(5, width - labelWidth - suffixWidth - 2);
62
+ const inputNodeRef = useRef<InputRenderable | null>(null);
63
+ const displayValue = field.valueText ?? formatInputNumber(field.value ?? 0, field.percent);
64
+ const fieldRef = useRef(field);
65
+ const displayValueRef = useRef(displayValue);
66
+ const [text, setText] = useState(displayValue);
67
+ const latestTextRef = useRef(displayValue);
68
+ const committedTextRef = useRef(displayValue);
69
+ const wasActiveRef = useRef(active);
70
+ const dirtyRef = useRef(false);
71
+ fieldRef.current = field;
72
+ displayValueRef.current = displayValue;
73
+ const focusInput = useCallback(() => {
74
+ const input = inputNodeRef.current;
75
+ try {
76
+ input?.focus?.();
77
+ input?.setCursorOffset?.(0);
78
+ } catch {
79
+ // Renderer teardown can race queued focus attempts.
80
+ }
81
+ }, []);
82
+ const fg = active
83
+ ? colors.selectedText
84
+ : field.tone === "positive"
85
+ ? colors.positive
86
+ : field.tone === "negative"
87
+ ? colors.negative
88
+ : colors.text;
89
+
90
+ const commitText = useCallback((nextText: string): string | null => {
91
+ const currentField = fieldRef.current;
92
+ if (nextText.trim() === "") {
93
+ currentField.onClear?.();
94
+ return null;
95
+ }
96
+ const parsed = parsePromptNumber(nextText);
97
+ if (parsed == null) return null;
98
+ const nextValue = currentField.percent ? parsed / 100 : parsed;
99
+ currentField.onValue?.(nextValue);
100
+ return formatInputNumber(nextValue, currentField.percent);
101
+ }, []);
102
+
103
+ const commitEditText = useCallback((nextText: string, fallbackText = displayValue) => {
104
+ latestTextRef.current = nextText;
105
+ const committedText = commitText(nextText);
106
+ committedTextRef.current = committedText ?? fallbackText;
107
+ setText(committedTextRef.current);
108
+ dirtyRef.current = false;
109
+ }, [commitText, displayValue]);
110
+
111
+ const getLiveInputText = useCallback(() => {
112
+ const input = inputNodeRef.current;
113
+ if (!input) return latestTextRef.current;
114
+ try {
115
+ return input.editBuffer.getText();
116
+ } catch {
117
+ return latestTextRef.current;
118
+ }
119
+ }, []);
120
+
121
+ const commitLiveInputText = useCallback(() => {
122
+ const liveText = getLiveInputText();
123
+ const nextText = typeof liveText === "string" ? liveText : latestTextRef.current;
124
+ if (!dirtyRef.current && (nextText.trim() === "" || nextText === committedTextRef.current)) return;
125
+ latestTextRef.current = nextText;
126
+ const committedText = commitText(nextText);
127
+ committedTextRef.current = committedText ?? displayValueRef.current;
128
+ dirtyRef.current = false;
129
+ }, [commitText, getLiveInputText]);
130
+
131
+ useLayoutEffect(() => {
132
+ const wasActive = wasActiveRef.current;
133
+ wasActiveRef.current = active;
134
+ if (!wasActive && active) {
135
+ dirtyRef.current = false;
136
+ latestTextRef.current = "";
137
+ committedTextRef.current = displayValue;
138
+ setText("");
139
+ return;
140
+ }
141
+ if (wasActive && !active) {
142
+ const nextText = dirtyRef.current ? latestTextRef.current : text;
143
+ if (dirtyRef.current) {
144
+ commitEditText(nextText);
145
+ } else {
146
+ committedTextRef.current = displayValue;
147
+ setText(displayValue);
148
+ }
149
+ return;
150
+ }
151
+ if (!active) {
152
+ latestTextRef.current = displayValue;
153
+ committedTextRef.current = displayValue;
154
+ setText(displayValue);
155
+ }
156
+ }, [active, commitEditText, displayValue, text]);
157
+
158
+ useEffect(() => {
159
+ if (!active) return;
160
+ let animationFrame: number | null = null;
161
+ const timeouts: ReturnType<typeof setTimeout>[] = [];
162
+ focusInput();
163
+ queueMicrotask(focusInput);
164
+ animationFrame = globalThis.requestAnimationFrame?.(focusInput) ?? null;
165
+ timeouts.push(setTimeout(focusInput, 0), setTimeout(focusInput, 32));
166
+ return () => {
167
+ if (animationFrame !== null) globalThis.cancelAnimationFrame?.(animationFrame);
168
+ for (const timeout of timeouts) clearTimeout(timeout);
169
+ };
170
+ }, [active, focusInput]);
171
+
172
+ useLayoutEffect(() => {
173
+ if (!active) return;
174
+ return () => {
175
+ commitLiveInputText();
176
+ };
177
+ }, [active, commitLiveInputText]);
178
+
179
+ const handleSubmit = (nextText: string) => {
180
+ commitEditText(nextText, nextText);
181
+ };
182
+
183
+ const handleBlur = (nextText: string) => {
184
+ if (!dirtyRef.current && (nextText.trim() === "" || nextText === committedTextRef.current)) return;
185
+ commitEditText(nextText, nextText);
186
+ };
187
+
188
+ if (field.onPress && !field.onValue) {
189
+ return (
190
+ <Box
191
+ width={width}
192
+ height={1}
193
+ flexDirection="row"
194
+ backgroundColor={active ? colors.selected : colors.panel}
195
+ data-gloom-field-id={field.id}
196
+ onMouseDown={() => {
197
+ onFocus();
198
+ field.onPress?.();
199
+ }}
200
+ >
201
+ <Text fg={active ? colors.selectedText : colors.textDim}>
202
+ {truncateText(field.label, labelWidth).padEnd(labelWidth)}
203
+ </Text>
204
+ <Text fg={fg} attributes={active ? TextAttributes.BOLD : 0}>
205
+ {truncateText(field.valueText ?? "", Math.max(1, width - labelWidth))}
206
+ </Text>
207
+ </Box>
208
+ );
209
+ }
210
+
211
+ return (
212
+ <Box
213
+ width={width}
214
+ height={1}
215
+ flexDirection="row"
216
+ backgroundColor={active ? colors.selected : colors.panel}
217
+ data-gloom-field-id={field.id}
218
+ onMouseDown={() => {
219
+ onFocus();
220
+ focusInput();
221
+ }}
222
+ >
223
+ <Text fg={active ? colors.selectedText : colors.textDim}>
224
+ {truncateText(field.label, labelWidth).padEnd(labelWidth)}
225
+ </Text>
226
+ {active ? (
227
+ <NumberField
228
+ inputRef={inputNodeRef}
229
+ focused={active}
230
+ value={text}
231
+ placeholder={displayValue}
232
+ allowNegative={field.allowNegative}
233
+ allowDecimal
234
+ width={valueWidth}
235
+ variant="plain"
236
+ backgroundColor={colors.selected}
237
+ textColor={fg}
238
+ placeholderColor={colors.textMuted}
239
+ onMouseDown={onFocus}
240
+ onChange={(nextText) => {
241
+ dirtyRef.current = true;
242
+ latestTextRef.current = nextText;
243
+ setText(nextText);
244
+ }}
245
+ onSubmit={handleSubmit}
246
+ onBlur={handleBlur}
247
+ />
248
+ ) : (
249
+ <Box width={valueWidth} height={1} onMouseDown={() => {
250
+ onFocus();
251
+ focusInput();
252
+ }}>
253
+ <Text fg={fg}>{truncateText(displayValue, valueWidth)}</Text>
254
+ </Box>
255
+ )}
256
+ {suffixWidth > 0 && (
257
+ <Text fg={active ? colors.selectedText : colors.textDim}>
258
+ {field.suffix ?? (field.percent ? "%" : "")}
259
+ </Text>
260
+ )}
261
+ </Box>
262
+ );
263
+ }
264
+
265
+ export function MetricLine({
266
+ label,
267
+ value,
268
+ detail,
269
+ color,
270
+ width,
271
+ }: {
272
+ label: string;
273
+ value: string;
274
+ detail?: string;
275
+ color?: string;
276
+ width: number;
277
+ }) {
278
+ const labelWidth = Math.min(12, Math.max(8, Math.floor(width * 0.32)));
279
+ const valueWidth = Math.min(Math.max(8, width - labelWidth), Math.max(8, Math.floor(width * 0.42)));
280
+ const detailWidth = Math.max(0, width - labelWidth - valueWidth);
281
+ return (
282
+ <Box height={1} width={width} flexDirection="row" overflow="hidden">
283
+ <Box width={labelWidth} flexShrink={0}>
284
+ <Text fg={colors.textDim}>{label}</Text>
285
+ </Box>
286
+ <Box width={valueWidth} flexShrink={0}>
287
+ <Text fg={color ?? colors.text} attributes={TextAttributes.BOLD}>
288
+ {truncateText(value, valueWidth)}
289
+ </Text>
290
+ </Box>
291
+ {detail && detailWidth > 0 && (
292
+ <Text fg={colors.textDim}>{truncateText(detail, detailWidth)}</Text>
293
+ )}
294
+ </Box>
295
+ );
296
+ }
297
+
298
+ export function SensitivityGridView({
299
+ width,
300
+ grid,
301
+ }: {
302
+ width: number;
303
+ grid: SensitivityGrid;
304
+ }) {
305
+ const labelWidth = 9;
306
+ const cellWidth = Math.max(8, Math.floor((width - labelWidth) / Math.max(1, grid.columns.length)));
307
+ return (
308
+ <Box flexDirection="column" paddingX={1}>
309
+ <Box height={1} flexDirection="row">
310
+ <Box width={labelWidth}>
311
+ <Text fg={colors.textDim}>{truncateText(grid.rowLabel, labelWidth)}</Text>
312
+ </Box>
313
+ {grid.columns.map((column) => (
314
+ <Box key={column} width={cellWidth}>
315
+ <Text fg={colors.textDim}>{truncateText(column, cellWidth - 1).padStart(cellWidth - 1)}</Text>
316
+ </Box>
317
+ ))}
318
+ </Box>
319
+ {grid.rows.map((row, rowIndex) => (
320
+ <Box key={row} height={1} flexDirection="row">
321
+ <Box width={labelWidth}>
322
+ <Text fg={colors.textDim}>{truncateText(row, labelWidth)}</Text>
323
+ </Box>
324
+ {grid.cells[rowIndex]?.map((cell, cellIndex) => (
325
+ <Box key={`${row}:${cellIndex}`} width={cellWidth}>
326
+ <Text fg={colors.text}>{truncateText(cell.text, cellWidth - 1).padStart(cellWidth - 1)}</Text>
327
+ </Box>
328
+ ))}
329
+ </Box>
330
+ ))}
331
+ </Box>
332
+ );
333
+ }
334
+
335
+ export function buildKellyCurveXAxisLabels(maxFraction: number): string[] {
336
+ if (!Number.isFinite(maxFraction) || maxFraction <= 0) return [];
337
+ return [0, 0.25, 0.5, 0.75, 1].map((ratio) => formatPct(maxFraction * ratio, 0));
338
+ }
339
+
340
+ export function KellyCurveDecisionView({
341
+ width,
342
+ currentFraction,
343
+ targetFraction,
344
+ fullKellyFraction,
345
+ currentGrowth,
346
+ targetGrowth,
347
+ addTrimValue,
348
+ currency,
349
+ clipReasons,
350
+ }: {
351
+ width: number;
352
+ currentFraction: number;
353
+ targetFraction: number;
354
+ fullKellyFraction: number;
355
+ currentGrowth: number;
356
+ targetGrowth: number;
357
+ addTrimValue: number;
358
+ currency: string;
359
+ clipReasons: string[];
360
+ }) {
361
+ const moveLabel = addTrimValue >= 0 ? "add" : "trim";
362
+ const capLabel = clipReasons.length > 0 ? `cap ${clipReasons.join(", ")}` : "cap none";
363
+ const text = [
364
+ `${formatPct(currentFraction, 1)} -> ${formatPct(targetFraction, 1)}`,
365
+ `${moveLabel} ${formatCurrency(Math.abs(addTrimValue), currency)}`,
366
+ `growth ${formatSignedPct(currentGrowth)} -> ${formatSignedPct(targetGrowth)}`,
367
+ `full ${formatPct(fullKellyFraction, 0)}`,
368
+ capLabel,
369
+ ].join(" ");
370
+ return (
371
+ <Box height={1} paddingX={1}>
372
+ <Text fg={colors.textDim}>{truncateText(text, Math.max(1, width - 2))}</Text>
373
+ </Box>
374
+ );
375
+ }
@@ -105,7 +105,11 @@ export const layoutManagerPlugin: GloomPlugin = {
105
105
  execute: async () => {
106
106
  if (!getStateRef) return;
107
107
  const { layout, termWidth, termHeight } = getStateRef();
108
- persistLayout(ctx, gridlockAllPanes(layout, { x: 0, y: 0, width: termWidth, height: termHeight }));
108
+ persistLayout(ctx, gridlockAllPanes(
109
+ layout,
110
+ { x: 0, y: 0, width: termWidth, height: termHeight },
111
+ ctx.getPaneDef,
112
+ ));
109
113
  notifyGridlockComplete(ctx.notify, () => {
110
114
  dispatchRef?.({ type: "UNDO_LAYOUT" });
111
115
  });
@@ -130,9 +130,9 @@ function optionMoneynessBackground(
130
130
  row: OptionTableRow,
131
131
  contract: OptionContract | undefined,
132
132
  columnId: OptionColumnId,
133
- rowState: { selected: boolean; hovered: boolean },
133
+ rowState: { selected: boolean },
134
134
  ): string | undefined {
135
- if (rowState.selected || rowState.hovered) return undefined;
135
+ if (rowState.selected) return undefined;
136
136
  const inTheMoney = inferColumnMoneyness(row, contract, columnId);
137
137
  const sideColor = columnId.startsWith("call") ? colors.positive : colors.negative;
138
138
  return inTheMoney
@@ -180,13 +180,13 @@ export function renderOptionCell(
180
180
  row: OptionTableRow,
181
181
  column: OptionColumn,
182
182
  _index: number,
183
- rowState: { selected: boolean; hovered: boolean },
183
+ rowState: { selected: boolean },
184
184
  ): DataTableCell {
185
185
  const selectedColor = rowState.selected ? colors.selectedText : undefined;
186
- const rowSurface = rowState.selected ? colors.selected : rowState.hovered ? hoverBg() : colors.bg;
186
+ const rowSurface = rowState.selected ? colors.selected : colors.bg;
187
187
 
188
188
  if (column.id === "strike") {
189
- const backgroundColor = rowState.selected || rowState.hovered
189
+ const backgroundColor = rowState.selected
190
190
  ? undefined
191
191
  : blendHex(colors.bg, row.isPositionStrike ? colors.borderFocused : colors.header, row.isPositionStrike ? 0.18 : 0.1);
192
192
  const surface = backgroundColor ?? rowSurface;
@@ -1,5 +1,6 @@
1
1
  import { afterEach, expect, test } from "bun:test";
2
2
  import { act } from "react";
3
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
4
  import { testRender } from "../../../renderers/opentui/test-utils";
4
5
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../market-data/coordinator";
5
6
  import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app/context";
@@ -7,6 +8,7 @@ import { createTestDataProvider } from "../../../test-support/data-provider";
7
8
  import { cloneLayout, createDefaultConfig } from "../../../types/config";
8
9
  import type { OptionContract, OptionsChain, TickerFinancials } from "../../../types/financials";
9
10
  import type { TickerRecord } from "../../../types/ticker";
11
+ import { formatExpDate } from "../../../utils/options";
10
12
  import { OptionsView } from "./view";
11
13
 
12
14
  const TEST_PANE_ID = "ticker-detail:options-test";
@@ -49,10 +51,14 @@ function makeContract(strike: number, side: "C" | "P", atmStrike = 101): OptionC
49
51
  };
50
52
  }
51
53
 
52
- function makeChain(strikes = [100, 101], atmStrike = 101): OptionsChain {
54
+ function makeChain(
55
+ strikes = [100, 101],
56
+ atmStrike = 101,
57
+ expirationDates = [1_782_345_600],
58
+ ): OptionsChain {
53
59
  return {
54
60
  underlyingSymbol: "AAPL",
55
- expirationDates: [1_782_345_600],
61
+ expirationDates,
56
62
  calls: strikes.map((strike) => makeContract(strike, "C", atmStrike)),
57
63
  puts: strikes.map((strike) => makeContract(strike, "P", atmStrike)),
58
64
  };
@@ -74,7 +80,17 @@ function makeFinancials(price: number): TickerFinancials {
74
80
  };
75
81
  }
76
82
 
77
- function OptionsHarness({ ticker, quotePrice }: { ticker: TickerRecord; quotePrice?: number }) {
83
+ function OptionsHarness({
84
+ ticker,
85
+ quotePrice,
86
+ width = 122,
87
+ onCapture = () => {},
88
+ }: {
89
+ ticker: TickerRecord;
90
+ quotePrice?: number;
91
+ width?: number;
92
+ onCapture?: (capturing: boolean) => void;
93
+ }) {
78
94
  const config = createDefaultConfig("/tmp/gloomberb-options-test");
79
95
  config.layout = {
80
96
  dockRoot: { kind: "pane", instanceId: TEST_PANE_ID },
@@ -98,7 +114,7 @@ function OptionsHarness({ ticker, quotePrice }: { ticker: TickerRecord; quotePri
98
114
  return (
99
115
  <AppContext value={{ state, dispatch: () => {} }}>
100
116
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
101
- <OptionsView width={122} height={14} focused onCapture={() => {}} />
117
+ <OptionsView width={width} height={14} focused onCapture={onCapture} />
102
118
  </PaneInstanceProvider>
103
119
  </AppContext>
104
120
  );
@@ -183,3 +199,124 @@ test("defaults the table around the nearest strike to the current quote", async
183
199
  expect(frame).toContain("120");
184
200
  expect(frame).not.toContain(" 50 ");
185
201
  });
202
+
203
+ test("lets the expiration tab row use the full available width", async () => {
204
+ const expirationDates = Array.from({ length: 9 }, (_, index) => (
205
+ Math.floor(Date.UTC(2026, index, 20) / 1000)
206
+ ));
207
+ const provider = createTestDataProvider({
208
+ getOptionsChain: async () => makeChain([100, 101], 101, expirationDates),
209
+ });
210
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
211
+
212
+ await act(async () => {
213
+ testSetup = await testRender(
214
+ <OptionsHarness ticker={makeTicker("AAPL")} width={122} />,
215
+ {
216
+ width: 124,
217
+ height: 16,
218
+ },
219
+ );
220
+ });
221
+
222
+ await renderSettled();
223
+
224
+ const frame = testSetup!.captureCharFrame();
225
+ expect(frame).toContain(formatExpDate(expirationDates.at(-1)!));
226
+ });
227
+
228
+ test("keeps expiration tabs independently scrollable from a narrow strike table", async () => {
229
+ const expirationDates = Array.from({ length: 12 }, (_, index) => (
230
+ Math.floor(Date.UTC(2026, index, 20) / 1000)
231
+ ));
232
+ const requestedExpirations: Array<number | undefined> = [];
233
+ const provider = createTestDataProvider({
234
+ getOptionsChain: async (_ticker, _exchange, expirationDate) => {
235
+ requestedExpirations.push(expirationDate);
236
+ return makeChain([100, 101], 101, expirationDates);
237
+ },
238
+ });
239
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
240
+ const captures: boolean[] = [];
241
+
242
+ await act(async () => {
243
+ testSetup = await testRender(
244
+ <OptionsHarness ticker={makeTicker("AAPL")} width={54} onCapture={(capturing) => captures.push(capturing)} />,
245
+ {
246
+ width: 56,
247
+ height: 16,
248
+ },
249
+ );
250
+ });
251
+
252
+ await renderSettled();
253
+ const bodyScroll = testSetup!.renderer.root.findDescendantById("options-table-body-scroll") as ScrollBoxRenderable | undefined;
254
+ const expirationTabsScroll = testSetup!.renderer.root.findDescendantById("options-expiration-tabs-scroll") as ScrollBoxRenderable | undefined;
255
+ expect(bodyScroll?.horizontalScrollBar.visible).toBe(true);
256
+ expect(expirationTabsScroll?.horizontalScrollBar.visible).toBe(false);
257
+ expect(testSetup!.captureCharFrame()).not.toContain(formatExpDate(expirationDates.at(-1)!));
258
+
259
+ await act(async () => {
260
+ testSetup!.mockInput.pressEnter();
261
+ await testSetup!.renderOnce();
262
+ });
263
+ await renderSettled();
264
+ expect(captures).toContain(true);
265
+ expect(captures.at(-1)).toBe(true);
266
+
267
+ for (let index = 1; index < expirationDates.length; index += 1) {
268
+ await act(async () => {
269
+ testSetup!.mockInput.pressKey("l");
270
+ await testSetup!.renderOnce();
271
+ });
272
+ await renderSettled();
273
+ }
274
+
275
+ expect(bodyScroll?.horizontalScrollBar.visible).toBe(true);
276
+ expect(bodyScroll?.scrollLeft ?? 0).toBe(0);
277
+ expect(expirationTabsScroll?.scrollLeft ?? 0).toBeGreaterThan(0);
278
+ expect(requestedExpirations).toContain(expirationDates.at(-1));
279
+ expect(testSetup!.captureCharFrame()).toContain(formatExpDate(expirationDates.at(-1)!));
280
+ });
281
+
282
+ test("clicking the option table focuses expiration tabs for arrow navigation", async () => {
283
+ const expirationDates = Array.from({ length: 3 }, (_, index) => (
284
+ Math.floor(Date.UTC(2026, index, 20) / 1000)
285
+ ));
286
+ const requestedExpirations: Array<number | undefined> = [];
287
+ const provider = createTestDataProvider({
288
+ getOptionsChain: async (_ticker, _exchange, expirationDate) => {
289
+ requestedExpirations.push(expirationDate);
290
+ return makeChain([100, 101], 101, expirationDates);
291
+ },
292
+ });
293
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
294
+ const captures: boolean[] = [];
295
+
296
+ await act(async () => {
297
+ testSetup = await testRender(
298
+ <OptionsHarness ticker={makeTicker("AAPL")} width={80} onCapture={(capturing) => captures.push(capturing)} />,
299
+ {
300
+ width: 82,
301
+ height: 16,
302
+ },
303
+ );
304
+ });
305
+
306
+ await renderSettled();
307
+
308
+ await act(async () => {
309
+ await testSetup!.mockMouse.click(8, 4);
310
+ await testSetup!.renderOnce();
311
+ });
312
+ await renderSettled();
313
+ expect(captures.at(-1)).toBe(true);
314
+
315
+ await act(async () => {
316
+ testSetup!.mockInput.pressArrow("right");
317
+ await testSetup!.renderOnce();
318
+ });
319
+ await renderSettled();
320
+
321
+ expect(requestedExpirations).toContain(expirationDates[1]);
322
+ });