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
@@ -29,6 +29,10 @@ export interface DataTableSectionHeader {
29
29
 
30
30
  export type DataTableScrollAlign = "nearest" | "center";
31
31
 
32
+ export interface DataTableRowState {
33
+ selected: boolean;
34
+ }
35
+
32
36
  export interface DataTableProps<
33
37
  T,
34
38
  C extends DataTableColumn = DataTableColumn,
@@ -42,14 +46,13 @@ export interface DataTableProps<
42
46
  scrollRef: RefObject<ScrollBoxRenderable | null>;
43
47
  syncHeaderScroll: () => void;
44
48
  onBodyScrollActivity: () => void;
45
- hoveredIdx: number | null;
46
- setHoveredIdx: (index: number | null) => void;
47
49
  headerScrollId?: string;
48
50
  bodyScrollId?: string;
49
51
  getItemKey: (item: T, index: number) => string;
50
52
  isSelected: (item: T, index: number) => boolean;
51
53
  onSelect: (item: T, index: number) => void;
52
54
  onActivate?: (item: T, index: number) => void;
55
+ onTableMouseDown?: (event: any) => void;
53
56
  onRowMouseDown?: (item: T, index: number, event: any) => boolean | void;
54
57
  onRowContextMenu?: (item: T, index: number, event: any) => void;
55
58
  rowContextMenuSurface?: boolean;
@@ -57,7 +60,7 @@ export interface DataTableProps<
57
60
  item: T,
58
61
  column: C,
59
62
  index: number,
60
- rowState: { selected: boolean; hovered: boolean },
63
+ rowState: DataTableRowState,
61
64
  ) => DataTableCell;
62
65
  renderSectionHeader?: (
63
66
  item: T,
@@ -66,7 +69,7 @@ export interface DataTableProps<
66
69
  getRowBackgroundColor?: (
67
70
  item: T,
68
71
  index: number,
69
- rowState: { selected: boolean; hovered: boolean },
72
+ rowState: DataTableRowState,
70
73
  ) => string | undefined;
71
74
  emptyContent?: ReactNode;
72
75
  bodyAfter?: ReactNode;
@@ -74,6 +77,9 @@ export interface DataTableProps<
74
77
  emptyStateHint?: string;
75
78
  virtualize?: boolean;
76
79
  overscan?: number;
80
+ columnGap?: number;
81
+ horizontalPadding?: number;
82
+ fillAvailableWidth?: boolean;
77
83
  showHorizontalScrollbar?: boolean;
78
84
  scrollToIndex?: number | null;
79
85
  scrollToIndexAlign?: DataTableScrollAlign;
@@ -12,6 +12,7 @@ export interface TextFieldProps {
12
12
  inputRef?: RefObject<InputRenderable | null>;
13
13
  onChange?: (value: string) => void;
14
14
  onSubmit?: (value: string) => void;
15
+ onBlur?: (value: string) => void;
15
16
  hint?: string;
16
17
  type?: "text" | "password";
17
18
  variant?: "default" | "plain";
@@ -36,6 +37,7 @@ export function TextField({
36
37
  inputRef,
37
38
  onChange,
38
39
  onSubmit,
40
+ onBlur,
39
41
  hint,
40
42
  type = "text",
41
43
  variant = "default",
@@ -56,6 +58,7 @@ export function TextField({
56
58
  inputRef={inputRef}
57
59
  onChange={onChange}
58
60
  onSubmit={onSubmit}
61
+ onBlur={onBlur}
59
62
  hint={hint}
60
63
  type={type}
61
64
  variant={variant}
@@ -118,17 +121,18 @@ export function TextField({
118
121
  selectionFg={isPassword ? backgroundColor : undefined}
119
122
  showCursor={!isPassword}
120
123
  onCursorChange={() => syncCursorOffset()}
121
- onInput={(nextValue) => {
124
+ onInput={(nextValue: string) => {
122
125
  currentValueRef.current = nextValue;
123
126
  syncCursorOffset(nextValue);
124
127
  onChange?.(nextValue);
125
128
  }}
126
- onChange={(nextValue) => {
129
+ onChange={(nextValue: string) => {
127
130
  currentValueRef.current = nextValue;
128
131
  syncCursorOffset(nextValue);
129
132
  onChange?.(nextValue);
130
133
  }}
131
134
  onSubmit={() => onSubmit?.(currentValueRef.current)}
135
+ onBlur={() => onBlur?.(currentValueRef.current)}
132
136
  />
133
137
  {isPassword && (
134
138
  <Box
@@ -178,11 +182,12 @@ function sanitizeNumberInput(value: string, allowDecimal: boolean, allowNegative
178
182
  return next;
179
183
  }
180
184
 
181
- export interface NumberFieldProps extends Omit<TextFieldProps, "onChange" | "onSubmit"> {
185
+ export interface NumberFieldProps extends Omit<TextFieldProps, "onChange" | "onSubmit" | "onBlur"> {
182
186
  allowDecimal?: boolean;
183
187
  allowNegative?: boolean;
184
188
  onChange?: (value: string) => void;
185
189
  onSubmit?: (value: string) => void;
190
+ onBlur?: (value: string) => void;
186
191
  }
187
192
 
188
193
  export function NumberField({
@@ -190,6 +195,7 @@ export function NumberField({
190
195
  allowNegative = false,
191
196
  onChange,
192
197
  onSubmit,
198
+ onBlur,
193
199
  ...props
194
200
  }: NumberFieldProps) {
195
201
  return (
@@ -197,6 +203,7 @@ export function NumberField({
197
203
  {...props}
198
204
  onChange={(nextValue) => onChange?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))}
199
205
  onSubmit={(nextValue) => onSubmit?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))}
206
+ onBlur={(nextValue) => onBlur?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))}
200
207
  />
201
208
  );
202
209
  }
@@ -166,7 +166,7 @@ export function ListView({
166
166
  key={item.id}
167
167
  height={1}
168
168
  backgroundColor={rowBg}
169
- onMouseMove={() => {
169
+ onMouseOver={() => {
170
170
  if (!disabled) {
171
171
  setHoveredIndex((current) => (current === index ? current : index));
172
172
  if (selectOnHover) onSelect?.(index);
@@ -69,7 +69,7 @@ export function PageStackView({
69
69
 
70
70
  if (!detailOpen) {
71
71
  return (
72
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
72
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} minHeight={0} overflow="hidden">
73
73
  {rootContent}
74
74
  </Box>
75
75
  );
@@ -79,6 +79,10 @@ export function PageStackView({
79
79
  <Box
80
80
  flexDirection="column"
81
81
  flexGrow={1}
82
+ flexShrink={1}
83
+ flexBasis={0}
84
+ minWidth={0}
85
+ minHeight={0}
82
86
  overflow="hidden"
83
87
  onMouseDown={handleMouseDown}
84
88
  >
@@ -111,7 +115,7 @@ export function PageStackView({
111
115
  )}
112
116
  {backHint ? <Text fg={colors.textMuted}>{backHint}</Text> : null}
113
117
  </Box>
114
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
118
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} minHeight={0} overflow="hidden">
115
119
  {detailContent}
116
120
  </Box>
117
121
  </Box>
@@ -18,6 +18,22 @@ describe("table layout", () => {
18
18
  expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 2)).toBe(true);
19
19
  });
20
20
 
21
+ test("supports tables without inter-column gaps", () => {
22
+ const tableWidth = getTableWidth([
23
+ { width: 12 },
24
+ { width: 8 },
25
+ ], 0);
26
+ const noPaddingWidth = getTableWidth([
27
+ { width: 12 },
28
+ { width: 8 },
29
+ ], 0, 0);
30
+
31
+ expect(tableWidth).toBe(22);
32
+ expect(noPaddingWidth).toBe(20);
33
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth, 0)).toBe(false);
34
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 1, 0)).toBe(true);
35
+ });
36
+
21
37
  test("keeps non-flex web table columns fixed", () => {
22
38
  const template = buildTableGridTemplateColumns([
23
39
  { width: 4, align: "right" },
@@ -39,4 +55,13 @@ describe("table layout", () => {
39
55
 
40
56
  expect(template).toBe("minmax(calc(8 * var(--cell-w)), 12fr) minmax(calc(8 * var(--cell-w)), 8fr)");
41
57
  });
58
+
59
+ test("keeps fixed web table columns when fill width is disabled", () => {
60
+ const template = buildTableGridTemplateColumns([
61
+ { width: 12 },
62
+ { width: 8, align: "right" },
63
+ ], false);
64
+
65
+ expect(template).toBe("minmax(calc(8 * var(--cell-w)), calc(12 * var(--cell-w))) minmax(calc(8 * var(--cell-w)), calc(8 * var(--cell-w)))");
66
+ });
42
67
  });
@@ -10,19 +10,29 @@ export interface TableWidthColumn {
10
10
  const TRAILING_COLUMN_GUTTER_WIDTH = 1;
11
11
  const WEB_CELL_UNIT = "var(--cell-w)";
12
12
 
13
- export function getTableWidth(columns: readonly TableWidthColumn[]): number {
14
- return columns.reduce((sum, column) => sum + column.width + 1, 2);
13
+ export function getTableWidth(
14
+ columns: readonly TableWidthColumn[],
15
+ columnGap = 1,
16
+ horizontalPadding = 1,
17
+ ): number {
18
+ return columns.reduce((sum, column) => sum + column.width + columnGap, horizontalPadding * 2);
15
19
  }
16
20
 
17
- export function hasMeaningfulTableHorizontalOverflow(tableWidth: number, viewportWidth: number): boolean {
18
- return viewportWidth > 0 && tableWidth - viewportWidth > TRAILING_COLUMN_GUTTER_WIDTH;
21
+ export function hasMeaningfulTableHorizontalOverflow(
22
+ tableWidth: number,
23
+ viewportWidth: number,
24
+ columnGap = TRAILING_COLUMN_GUTTER_WIDTH,
25
+ ): boolean {
26
+ return viewportWidth > 0 && tableWidth - viewportWidth > columnGap;
19
27
  }
20
28
 
21
29
  export function expandTableColumns<C extends TableWidthColumn>(
22
30
  columns: readonly C[],
23
31
  targetWidth: number,
32
+ columnGap = 1,
33
+ horizontalPadding = 1,
24
34
  ): C[] {
25
- const currentWidth = getTableWidth(columns);
35
+ const currentWidth = getTableWidth(columns, columnGap, horizontalPadding);
26
36
  const extraWidth = Math.floor(targetWidth) - currentWidth;
27
37
  if (extraWidth <= 0) return [...columns];
28
38
 
@@ -62,12 +72,18 @@ function cellWidthCss(width: number): string {
62
72
  return `calc(${width} * ${WEB_CELL_UNIT})`;
63
73
  }
64
74
 
65
- export function buildTableGridTemplateColumns(columns: readonly TableWidthColumn[]): string {
75
+ export function buildTableGridTemplateColumns(
76
+ columns: readonly TableWidthColumn[],
77
+ fillAvailableWidth = true,
78
+ ): string {
66
79
  const hasFlexColumn = columns.some((column) => (column.flexGrow ?? 0) > 0);
67
80
  return columns
68
81
  .map((column) => {
69
82
  const width = normalizedColumnWidth(column);
70
83
  const minWidth = cellWidthCss(columnMinCh(column));
84
+ if (!fillAvailableWidth) {
85
+ return `minmax(${minWidth}, ${cellWidthCss(width)})`;
86
+ }
71
87
  if (!hasFlexColumn || (column.flexGrow ?? 0) > 0) {
72
88
  return `minmax(${minWidth}, ${columnFlexWeight(column)}fr)`;
73
89
  }
@@ -31,6 +31,7 @@ export interface TabsProps {
31
31
  focused?: boolean;
32
32
  keyboardNavigation?: boolean;
33
33
  scrollable?: boolean;
34
+ scrollId?: string;
34
35
  }
35
36
 
36
37
  const WHEEL_DELTA_PER_CELL = 8;
@@ -47,6 +48,7 @@ export function Tabs({
47
48
  focused = false,
48
49
  keyboardNavigation = true,
49
50
  scrollable = true,
51
+ scrollId,
50
52
  }: TabsProps) {
51
53
  const ui = useUiHost();
52
54
  const NativeTabs = ui.Tabs;
@@ -57,11 +59,11 @@ export function Tabs({
57
59
  navigationValueRef.current = activeValue;
58
60
  }
59
61
  const palette = {
60
- activeFg: variant === "bare" ? colors.textBright : colors.text,
62
+ activeFg: focused || variant === "bare" ? colors.textBright : colors.text,
61
63
  inactiveFg: colors.textDim,
62
64
  disabledFg: colors.textMuted,
63
65
  hoverFg: colors.text,
64
- activeUnderline: colors.borderFocused,
66
+ activeUnderline: focused ? colors.textBright : colors.borderFocused,
65
67
  inactiveUnderline: colors.bg,
66
68
  hoverUnderline: colors.border,
67
69
  hoverBg: hoverBg(),
@@ -118,6 +120,7 @@ export function Tabs({
118
120
  closeMode={closeMode}
119
121
  addLabel={addLabel}
120
122
  onAdd={onAdd}
123
+ focused={focused}
121
124
  palette={palette}
122
125
  />
123
126
  );
@@ -133,7 +136,9 @@ export function Tabs({
133
136
  closeMode={closeMode}
134
137
  addLabel={addLabel}
135
138
  onAdd={onAdd}
139
+ focused={focused}
136
140
  scrollable={scrollable}
141
+ scrollId={scrollId}
137
142
  palette={palette}
138
143
  />
139
144
  );
@@ -157,7 +162,9 @@ function OpenTuiTabs({
157
162
  closeMode = "always",
158
163
  addLabel = "+",
159
164
  onAdd,
165
+ focused = false,
160
166
  scrollable = true,
167
+ scrollId,
161
168
  palette,
162
169
  }: TabsProps & {
163
170
  palette: {
@@ -187,7 +194,7 @@ function OpenTuiTabs({
187
194
  useEffect(() => {
188
195
  const scrollBox = scrollRef.current;
189
196
  const activeIndex = tabs.findIndex((tab) => tab.value === activeValue);
190
- const viewportWidth = scrollBox?.viewport?.width ?? 0;
197
+ const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || 0;
191
198
  if (!scrollBox || activeIndex < 0 || viewportWidth <= 0) return;
192
199
 
193
200
  const activeLeft = tabWidths.slice(0, activeIndex).reduce((sum, width) => sum + width, 0);
@@ -195,14 +202,15 @@ function OpenTuiTabs({
195
202
  const currentLeft = scrollBox.scrollLeft ?? 0;
196
203
  const currentRight = currentLeft + viewportWidth;
197
204
  const maxScrollLeft = Math.max(0, totalWidth - viewportWidth);
205
+ const scrollToLeft = (left: number) => {
206
+ scrollBox.scrollLeft = left;
207
+ scrollBox.scrollTo({ x: left, y: scrollBox.scrollTop });
208
+ };
198
209
 
199
210
  if (activeLeft < currentLeft) {
200
- scrollBox.scrollTo({ x: activeLeft, y: scrollBox.scrollTop });
211
+ scrollToLeft(activeLeft);
201
212
  } else if (activeRight > currentRight) {
202
- scrollBox.scrollTo({
203
- x: Math.min(activeRight - viewportWidth, maxScrollLeft),
204
- y: scrollBox.scrollTop,
205
- });
213
+ scrollToLeft(Math.min(activeRight - viewportWidth, maxScrollLeft));
206
214
  }
207
215
  }, [activeValue, tabWidths, tabs, totalWidth]);
208
216
 
@@ -214,7 +222,7 @@ function OpenTuiTabs({
214
222
  const direction = event?.scroll?.direction;
215
223
  if (!direction) return;
216
224
  const scrollBox = scrollRef.current;
217
- const viewportWidth = scrollBox?.viewport?.width ?? 0;
225
+ const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || 0;
218
226
  if (!scrollBox || viewportWidth <= 0 || totalWidth <= viewportWidth) return;
219
227
 
220
228
  event.preventDefault?.();
@@ -228,6 +236,7 @@ function OpenTuiTabs({
228
236
  0,
229
237
  Math.min(maxScrollLeft, (scrollBox.scrollLeft ?? 0) + directionSign * deltaCells),
230
238
  );
239
+ scrollBox.scrollLeft = nextLeft;
231
240
  scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
232
241
  };
233
242
 
@@ -236,10 +245,16 @@ function OpenTuiTabs({
236
245
  {tabs.map((tab, index) => {
237
246
  const active = tab.value === activeValue;
238
247
  const hovered = hoveredValue === tab.value && !tab.disabled;
248
+ const focusedActive = focused && active;
239
249
  const tabWidth = tabWidths[index] ?? tab.label.length + 2;
240
250
  const showClose = !!tab.onClose && (closeMode === "always" || active);
241
251
  const attributes = (active ? TextAttributes.BOLD : 0)
242
- | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
252
+ | (
253
+ (variant === "underline" && !compact && (active || hovered))
254
+ || (variant === "bare" && focusedActive)
255
+ ? TextAttributes.UNDERLINE
256
+ : 0
257
+ );
243
258
  const startHover = tab.disabled
244
259
  ? undefined
245
260
  : () => {
@@ -272,7 +287,6 @@ function OpenTuiTabs({
272
287
  flexDirection="row"
273
288
  backgroundColor={active && variant === "pill" ? palette.activeBg : hovered ? palette.hoverBg : undefined}
274
289
  onMouseOver={startHover}
275
- onMouseMove={startHover}
276
290
  onMouseOut={endHover}
277
291
  onMouseDown={selectTab}
278
292
  onDoubleClick={tab.disabled || !tab.onDoubleClick ? undefined : () => tab.onDoubleClick?.(tab.value)}
@@ -304,7 +318,7 @@ function OpenTuiTabs({
304
318
  width={addWidth}
305
319
  height={1}
306
320
  backgroundColor={hoveredValue === "__add__" ? palette.hoverBg : undefined}
307
- onMouseMove={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
321
+ onMouseOver={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
308
322
  onMouseOut={() => setHoveredValue((current) => (current === "__add__" ? null : current))}
309
323
  onMouseDown={(event: TabPointerEvent) => {
310
324
  event.preventDefault();
@@ -323,6 +337,7 @@ function OpenTuiTabs({
323
337
 
324
338
  return (
325
339
  <ScrollBox
340
+ id={scrollId}
326
341
  ref={scrollRef}
327
342
  width="100%"
328
343
  height={1}
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useEffect, useRef, useState } from "react";
3
3
  import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
4
+ import { TextAttributes } from "../../ui";
4
5
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
5
6
  import { ChoiceDialog } from "./choice-dialog";
6
7
  import { DataTable, type DataTableColumn } from "./data-table";
@@ -50,7 +51,6 @@ function DataTableActivationHarness() {
50
51
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
51
52
  const scrollRef = useRef<ScrollBoxRenderable>(null);
52
53
  const [selectedId, setSelectedId] = useState<string | null>(null);
53
- const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
54
54
 
55
55
  return (
56
56
  <DataTable
@@ -63,8 +63,6 @@ function DataTableActivationHarness() {
63
63
  scrollRef={scrollRef}
64
64
  syncHeaderScroll={() => {}}
65
65
  onBodyScrollActivity={() => {}}
66
- hoveredIdx={hoveredIdx}
67
- setHoveredIdx={setHoveredIdx}
68
66
  getItemKey={(row) => row.id}
69
67
  isSelected={(row) => selectedId === row.id}
70
68
  onSelect={(row) => {
@@ -88,7 +86,6 @@ function DataTableSectionHarness() {
88
86
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
89
87
  const scrollRef = useRef<ScrollBoxRenderable>(null);
90
88
  const [selectedId, setSelectedId] = useState<string | null>(null);
91
- const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
92
89
  const rows: SectionTableRow[] = [
93
90
  { kind: "header", id: "macro", label: "Macro Releases" },
94
91
  { kind: "row", id: "cpi", name: "CPI" },
@@ -105,8 +102,6 @@ function DataTableSectionHarness() {
105
102
  scrollRef={scrollRef}
106
103
  syncHeaderScroll={() => {}}
107
104
  onBodyScrollActivity={() => {}}
108
- hoveredIdx={hoveredIdx}
109
- setHoveredIdx={setHoveredIdx}
110
105
  getItemKey={(row) => row.id}
111
106
  isSelected={(row) => row.kind === "row" && row.id === selectedId}
112
107
  onSelect={(row) => {
@@ -128,12 +123,18 @@ function DataTableHorizontalScrollHarness({
128
123
  containerWidth = 32,
129
124
  containerHeight = 5,
130
125
  rowCount = 1,
126
+ columnGap,
127
+ horizontalPadding,
128
+ fillAvailableWidth,
131
129
  showHorizontalScrollbar,
132
130
  }: {
133
131
  columns?: DataTableColumn[];
134
132
  containerWidth?: number;
135
133
  containerHeight?: number;
136
134
  rowCount?: number;
135
+ columnGap?: number;
136
+ horizontalPadding?: number;
137
+ fillAvailableWidth?: boolean;
137
138
  showHorizontalScrollbar?: boolean;
138
139
  }) {
139
140
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
@@ -162,15 +163,48 @@ function DataTableHorizontalScrollHarness({
162
163
  getItemKey={(row) => row.id}
163
164
  renderCell={(row) => ({ text: row.name })}
164
165
  emptyStateTitle="No rows."
166
+ columnGap={columnGap}
167
+ horizontalPadding={horizontalPadding}
168
+ fillAvailableWidth={fillAvailableWidth}
165
169
  showHorizontalScrollbar={showHorizontalScrollbar}
166
170
  />
167
171
  );
168
172
  }
169
173
 
174
+ function DataTableColumnGapHarness() {
175
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
176
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
177
+ const items = [{ id: "row-0", left: "AA", right: "BB" }];
178
+
179
+ return (
180
+ <DataTableView
181
+ rootWidth={8}
182
+ rootHeight={4}
183
+ selection={{ kind: "none" }}
184
+ columns={[
185
+ { id: "left", label: "L", width: 2, align: "left" },
186
+ { id: "right", label: "R", width: 2, align: "left" },
187
+ ]}
188
+ items={items}
189
+ sortColumnId={null}
190
+ sortDirection="asc"
191
+ onHeaderClick={() => {}}
192
+ headerScrollRef={headerScrollRef}
193
+ scrollRef={scrollRef}
194
+ getItemKey={(row) => row.id}
195
+ renderCell={(row, column) => ({ text: column.id === "left" ? row.left : row.right })}
196
+ emptyStateTitle="No rows."
197
+ columnGap={0}
198
+ horizontalPadding={0}
199
+ fillAvailableWidth={false}
200
+ showHorizontalScrollbar={false}
201
+ />
202
+ );
203
+ }
204
+
170
205
  function DataTableVirtualizationHarness() {
171
206
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
172
207
  const scrollRef = useRef<ScrollBoxRenderable>(null);
173
- const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
174
208
  const rows = Array.from({ length: 100 }, (_, index) => ({
175
209
  id: `row-${index}`,
176
210
  name: `Row ${index}`,
@@ -196,8 +230,6 @@ function DataTableVirtualizationHarness() {
196
230
  scrollRef={scrollRef}
197
231
  syncHeaderScroll={() => {}}
198
232
  onBodyScrollActivity={() => {}}
199
- hoveredIdx={hoveredIdx}
200
- setHoveredIdx={setHoveredIdx}
201
233
  getItemKey={(row) => row.id}
202
234
  isSelected={() => false}
203
235
  onSelect={() => {}}
@@ -360,6 +392,31 @@ describe("shared UI kit", () => {
360
392
  expect(lastSelected).toBe("chart");
361
393
  });
362
394
 
395
+ test("marks the active bare tab when it has keyboard focus", async () => {
396
+ testSetup = await testRender(
397
+ <Tabs
398
+ tabs={[
399
+ { label: "One", value: "one" },
400
+ { label: "Two", value: "two" },
401
+ ]}
402
+ activeValue="one"
403
+ onSelect={() => {}}
404
+ variant="bare"
405
+ compact
406
+ focused
407
+ />,
408
+ { width: 24, height: 2 },
409
+ );
410
+
411
+ await act(async () => {
412
+ await testSetup!.renderOnce();
413
+ });
414
+
415
+ const activeSpan = testSetup.captureSpans().lines[0]?.spans.find((span) => span.text.includes("One"));
416
+ expect(activeSpan).toBeDefined();
417
+ expect((activeSpan!.attributes & TextAttributes.UNDERLINE)).toBe(TextAttributes.UNDERLINE);
418
+ });
419
+
363
420
  test("selects tabs by clicking their text labels", async () => {
364
421
  let lastSelected = "overview";
365
422
  const selectedValues: string[] = [];
@@ -737,6 +794,27 @@ describe("shared UI kit", () => {
737
794
  expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(false);
738
795
  });
739
796
 
797
+ test("renders data table columns without spacer cells when column gap is zero", async () => {
798
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
799
+ testSetup = await testRender(
800
+ <AppContext value={{ state, dispatch: () => {} }}>
801
+ <PaneInstanceProvider paneId="portfolio-list:main">
802
+ <DataTableColumnGapHarness />
803
+ </PaneInstanceProvider>
804
+ </AppContext>,
805
+ { width: 8, height: 4 },
806
+ );
807
+
808
+ await act(async () => {
809
+ await testSetup!.renderOnce();
810
+ await testSetup!.renderOnce();
811
+ });
812
+
813
+ const frame = testSetup.captureCharFrame();
814
+ expect(frame).toContain("AABB");
815
+ expect(frame).not.toContain("AA BB");
816
+ });
817
+
740
818
  test("hides data table horizontal scrolling when content fits", async () => {
741
819
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
742
820
  tableScrollBoxForTest = null;
@@ -1,4 +1,4 @@
1
- import { cloneLayout, DEFAULT_LAYOUT, type SavedLayout } from "../../../types/config";
1
+ import { cloneLayout, createBlankLayout, type SavedLayout } from "../../../types/config";
2
2
  import {
3
3
  clonePaneStateMap,
4
4
  cloneSavedLayout,
@@ -96,7 +96,7 @@ export function reduceLayoutAction(state: AppState, action: AppAction): AppState
96
96
  );
97
97
  const newLayout: SavedLayout = {
98
98
  name: action.name,
99
- layout: cloneLayout(DEFAULT_LAYOUT),
99
+ layout: createBlankLayout(),
100
100
  paneState: {},
101
101
  };
102
102
  const layouts = [...currentConfig.layouts, newLayout];
@@ -85,7 +85,7 @@ export function PickerRow({
85
85
  return (
86
86
  <Box
87
87
  flexDirection="column"
88
- onMouseMove={onFocus}
88
+ onMouseOver={onFocus}
89
89
  onMouseDown={(event?: { stopPropagation?: () => void; preventDefault?: () => void }) => {
90
90
  event?.stopPropagation?.();
91
91
  event?.preventDefault?.();
@@ -135,7 +135,7 @@ export function CheckboxRow({
135
135
  <Box
136
136
  flexDirection="column"
137
137
  backgroundColor={hovered ? hoverBg() : undefined}
138
- onMouseMove={() => {
138
+ onMouseOver={() => {
139
139
  setHovered(true);
140
140
  onFocus();
141
141
  }}
@@ -214,7 +214,7 @@ export function AlertsPane({ focused, width, height, close }: PaneProps) {
214
214
  alert: AlertRule,
215
215
  column: AlertColumn,
216
216
  _index: number,
217
- rowState: { selected: boolean; hovered: boolean },
217
+ rowState: { selected: boolean },
218
218
  ): DataTableCell => {
219
219
  const selectedColor = rowState.selected ? colors.selectedText : undefined;
220
220
  const actionMouseDown = (handler: () => void) => (
@@ -293,12 +293,14 @@ describe("PortfolioAnalyticsPane", () => {
293
293
  currency: "USD",
294
294
  source: "flex",
295
295
  updatedAt: new Date(2026, 2, 27).getTime(),
296
+ asOfDate: "2026-03-26",
296
297
  totalCashValue: -50000,
297
298
  settledCash: -45000,
298
299
  availableFunds: 15000,
299
300
  excessLiquidity: 12000,
300
301
  buyingPower: 30000,
301
302
  netLiquidation: 125000,
303
+ grossPositionValue: 113636,
302
304
  dailyPnl: 900,
303
305
  unrealizedPnl: 777,
304
306
  realizedPnl: -25,
@@ -318,6 +320,8 @@ describe("PortfolioAnalyticsPane", () => {
318
320
 
319
321
  const frame = testSetup!.captureCharFrame();
320
322
  expect(frame).toContain("Net Liq 125k");
323
+ expect(frame).toContain("Val 113.6k");
324
+ expect(frame).toContain("Margin Lev 1.1x");
321
325
  expect(frame).toContain("Cash -50k");
322
326
  expect(frame).toContain("Day +900");
323
327
  expect(frame).toContain("P&L +777");
@@ -326,8 +330,8 @@ describe("PortfolioAnalyticsPane", () => {
326
330
  expect(frame).toContain("Avail 15k");
327
331
  expect(frame).toContain("Excess 12k");
328
332
  expect(frame).toContain("BP 30k");
329
- expect(frame).toContain("Synced just now");
330
- expect(frame).toContain("Source Flex Mar 27");
333
+ expect(frame).toContain("As Of Mar 26");
334
+ expect(frame).toContain("Source Flex Mar 26");
331
335
  });
332
336
 
333
337
  test("falls back from a Gateway portfolio to a configured Flex profile for IBKR history", async () => {