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
@@ -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
  });
@@ -1,55 +1,32 @@
1
1
  import { useCallback, useMemo } from "react";
2
2
  import { usePluginState } from "../../../runtime";
3
+ import {
4
+ DEFAULT_MAX_READ_IDS,
5
+ markReadId,
6
+ normalizeReadIds,
7
+ sameStringArray,
8
+ } from "../../shared/read-state";
3
9
 
4
10
  export interface NewsReadState {
5
11
  articleIds: string[];
6
12
  }
7
13
 
8
14
  const NEWS_READ_STATE_SCHEMA_VERSION = 1;
9
- export const MAX_READ_ARTICLE_IDS = 2_000;
15
+ export const MAX_READ_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
10
16
 
11
17
  const READ_STATE_KEY = "read-articles";
12
18
  const EMPTY_READ_STATE: NewsReadState = { articleIds: [] };
13
19
 
14
- function normalizeArticleId(articleId: string): string {
15
- return articleId.trim();
16
- }
17
-
18
- function sameStringArray(left: string[], right: string[]): boolean {
19
- if (left.length !== right.length) return false;
20
- return left.every((value, index) => value === right[index]);
21
- }
22
-
23
20
  export function normalizeNewsReadState(state: NewsReadState): NewsReadState {
24
- const seen = new Set<string>();
25
- const articleIds: string[] = [];
26
-
27
- for (const rawId of state.articleIds) {
28
- if (typeof rawId !== "string") continue;
29
- const articleId = normalizeArticleId(rawId);
30
- if (!articleId || seen.has(articleId)) continue;
31
- seen.add(articleId);
32
- articleIds.push(articleId);
33
- if (articleIds.length >= MAX_READ_ARTICLE_IDS) break;
34
- }
35
-
36
- return { articleIds };
21
+ return { articleIds: normalizeReadIds(state.articleIds, MAX_READ_ARTICLE_IDS) };
37
22
  }
38
23
 
39
24
  export function markNewsArticleRead(
40
25
  state: NewsReadState,
41
26
  articleId: string,
42
27
  ): NewsReadState {
43
- const normalizedId = normalizeArticleId(articleId);
44
28
  const current = normalizeNewsReadState(state).articleIds;
45
- if (!normalizedId) {
46
- return sameStringArray(current, state.articleIds) ? state : { articleIds: current };
47
- }
48
-
49
- const articleIds = [
50
- normalizedId,
51
- ...current.filter((currentId) => currentId !== normalizedId),
52
- ].slice(0, MAX_READ_ARTICLE_IDS);
29
+ const articleIds = markReadId(current, articleId, MAX_READ_ARTICLE_IDS);
53
30
 
54
31
  return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
55
32
  }
@@ -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
+ });