gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,13 +1,17 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act, useState } from "react";
2
+ import { act, useEffect, useRef, useState } from "react";
3
3
  import { testRender } from "../renderers/opentui/test-utils";
4
4
  import type { ColumnConfig } from "../types/config";
5
5
  import type { TickerFinancials } from "../types/financials";
6
6
  import type { TickerRecord } from "../types/ticker";
7
+ import type { ScrollBoxRenderable } from "../ui";
7
8
  import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
9
+ import { TickerListTableView } from "./ticker-list-table-view";
8
10
 
9
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
12
  let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
13
+ let setHarnessTickers: ((tickers: TickerRecord[]) => void) | null = null;
14
+ let tableScrollRef: ScrollBoxRenderable | null = null;
11
15
  let resolveCellCallCount = 0;
12
16
 
13
17
  const columns: ColumnConfig[] = [
@@ -60,6 +64,16 @@ function TickerListTableHarness() {
60
64
 
61
65
  function LargeTickerListTableHarness() {
62
66
  const [cursorSymbol, setCursorSymbol] = useState("T0");
67
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
68
+
69
+ useEffect(() => {
70
+ tableScrollRef = scrollRef.current;
71
+ return () => {
72
+ if (tableScrollRef === scrollRef.current) {
73
+ tableScrollRef = null;
74
+ }
75
+ };
76
+ });
63
77
 
64
78
  return (
65
79
  <TickerListTable
@@ -71,6 +85,35 @@ function LargeTickerListTableHarness() {
71
85
  setCursorSymbol={setCursorSymbol}
72
86
  resolveCell={resolveCell}
73
87
  financialsMap={financialsMap}
88
+ scrollRef={scrollRef}
89
+ />
90
+ );
91
+ }
92
+
93
+ function ReorderingTickerListTableViewHarness() {
94
+ const [rows, setRows] = useState(manyTickers);
95
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
96
+ setHarnessTickers = setRows;
97
+
98
+ useEffect(() => {
99
+ tableScrollRef = scrollRef.current;
100
+ return () => {
101
+ if (tableScrollRef === scrollRef.current) {
102
+ tableScrollRef = null;
103
+ }
104
+ };
105
+ });
106
+
107
+ return (
108
+ <TickerListTableView
109
+ focused
110
+ columns={columns}
111
+ tickers={rows}
112
+ cursorSymbol="T0"
113
+ setCursorSymbol={() => {}}
114
+ resolveCell={resolveCell}
115
+ financialsMap={financialsMap}
116
+ scrollRef={scrollRef}
74
117
  />
75
118
  );
76
119
  }
@@ -84,6 +127,8 @@ afterEach(async () => {
84
127
  }
85
128
  resolveCellCallCount = 0;
86
129
  setHarnessCursorSymbol = null;
130
+ setHarnessTickers = null;
131
+ tableScrollRef = null;
87
132
  });
88
133
 
89
134
  describe("TickerListTable", () => {
@@ -125,5 +170,41 @@ describe("TickerListTable", () => {
125
170
  expect(frame).toContain("T0");
126
171
  expect(frame).not.toContain("T999");
127
172
  expect(resolveCellCallCount).toBeLessThan(40);
173
+
174
+ await act(async () => {
175
+ for (let index = 0; index < 12; index++) {
176
+ await testSetup!.mockMouse.scroll(2, 2, "down");
177
+ }
178
+ await Promise.resolve();
179
+ await testSetup!.renderOnce();
180
+ });
181
+
182
+ const scrollTop = tableScrollRef?.scrollTop ?? 0;
183
+ expect(scrollTop).toBeGreaterThan(0);
184
+ expect(testSetup.captureCharFrame()).toContain(`T${scrollTop}`);
185
+ });
186
+
187
+ test("preserves manual scroll when market data reorders rows around the same cursor", async () => {
188
+ testSetup = await testRender(
189
+ <ReorderingTickerListTableViewHarness />,
190
+ { width: 20, height: 8 },
191
+ );
192
+
193
+ await act(async () => {
194
+ await testSetup!.renderOnce();
195
+ await testSetup!.renderOnce();
196
+ });
197
+
198
+ tableScrollRef!.scrollTop = 20;
199
+ expect(tableScrollRef?.scrollTop).toBe(20);
200
+
201
+ await act(async () => {
202
+ setHarnessTickers?.([...manyTickers.slice(1), manyTickers[0]!]);
203
+ await Promise.resolve();
204
+ await testSetup!.renderOnce();
205
+ await testSetup!.renderOnce();
206
+ });
207
+
208
+ expect(tableScrollRef?.scrollTop).toBe(20);
128
209
  });
129
210
  });
@@ -1,8 +1,18 @@
1
- import { Box, ScrollBox, Text, tickerContextMenuItems, useContextMenu, useRendererHost, useUiCapabilities } from "../ui";
1
+ import {
2
+ Box,
3
+ ScrollBox,
4
+ Text,
5
+ tickerContextMenuItems,
6
+ useContextMenu,
7
+ useNativeRenderer,
8
+ useRendererHost,
9
+ useUiCapabilities,
10
+ } from "../ui";
2
11
  import { memo, useCallback, useMemo, useRef, useState, type RefObject } from "react";
3
12
  import { TextAttributes, type ScrollBoxRenderable } from "../ui";
4
13
  import { EmptyState } from "./ui";
5
14
  import { colors, hoverBg } from "../theme/colors";
15
+ import { useThemeColors } from "../theme/theme-context";
6
16
  import { getSharedRegistry } from "../plugins/registry";
7
17
  import type { ColumnConfig } from "../types/config";
8
18
  import type { TickerFinancials, PricePoint } from "../types/financials";
@@ -12,8 +22,8 @@ import { renderChart, resolveChartPalette, type StyledContent } from "./chart/ch
12
22
  import type { ProjectedChartPoint } from "./chart/chart-data";
13
23
  import { tableContentWidthProps, useMeasuredTableContentWidth } from "./ui/table-layout";
14
24
  import { useViewport } from "../react/input";
15
- import { useRafCallback } from "../react/use-raf-callback";
16
25
  import { measurePerf } from "../utils/perf-marks";
26
+ import { useScrollBoxScrollActivity } from "./table-view-shared";
17
27
 
18
28
  export interface TickerTableCell {
19
29
  text: string;
@@ -102,6 +112,7 @@ const TickerListHeader = memo(function TickerListHeader({
102
112
  onHeaderClick?: (columnId: string) => void;
103
113
  onSizeChange?: () => void;
104
114
  }) {
115
+ useThemeColors();
105
116
  return (
106
117
  <ScrollBox
107
118
  ref={headerScrollRef}
@@ -211,6 +222,7 @@ const TickerListRow = memo(function TickerListRow({
211
222
  priceHistory?: PricePoint[];
212
223
  contentWidth: number;
213
224
  }) {
225
+ useThemeColors();
214
226
  const lastActivatedAtRef = useRef<number | null>(null);
215
227
  const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
216
228
 
@@ -328,6 +340,7 @@ export function TickerListTable({
328
340
  const hoveredIdxRef = useRef(hoveredIdx);
329
341
  const appViewport = useViewport();
330
342
  const renderer = useRendererHost();
343
+ const nativeRenderer = useNativeRenderer();
331
344
  const { showContextMenu } = useContextMenu();
332
345
  const { nativeContextMenu } = useUiCapabilities();
333
346
  const [scrollVersion, setScrollVersion] = useState(0);
@@ -387,12 +400,16 @@ export function TickerListTable({
387
400
  setScrollVersion((current) => current + 1);
388
401
  }
389
402
  onBodyScrollActivity?.();
390
- }, [onBodyScrollActivity, virtualize]);
403
+ nativeRenderer.requestRender();
404
+ }, [nativeRenderer, onBodyScrollActivity, virtualize]);
391
405
  const syncHeader = useCallback(() => {
392
406
  syncHeaderScroll?.();
393
407
  }, [syncHeaderScroll]);
394
- const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
395
- const scheduleHeaderScrollSync = useRafCallback(syncHeader);
408
+ useScrollBoxScrollActivity({
409
+ scrollRef: effectiveScrollRef,
410
+ onVerticalScroll: handleBodyScrollActivity,
411
+ onHorizontalScroll: syncHeader,
412
+ });
396
413
  const setHoveredIdxIfChanged = useCallback((index: number | null) => {
397
414
  if (hoveredIdxRef.current === index) return;
398
415
  setHoveredIdx(index);
@@ -448,10 +465,6 @@ export function TickerListTable({
448
465
  scrollX
449
466
  scrollY
450
467
  focusable={false}
451
- onMouseDown={scheduleHeaderScrollSync}
452
- onMouseUp={scheduleBodyScrollActivity}
453
- onMouseDrag={scheduleBodyScrollActivity}
454
- onMouseScroll={scheduleBodyScrollActivity}
455
468
  onSizeChange={measureContentWidth}
456
469
  >
457
470
  {tickers.length === 0 ? (
@@ -1,14 +1,15 @@
1
- import { Box, ScrollBox, Text, useUiHost } from "../../ui";
1
+ import { Box, ScrollBox, Text, useNativeRenderer, useUiHost } from "../../ui";
2
2
  import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, type RefObject } from "react";
3
3
  import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
4
4
  import { colors, hoverBg } from "../../theme/colors";
5
+ import { useThemeColors } from "../../theme/theme-context";
5
6
  import type { ColumnConfig } from "../../types/config";
6
7
  import { useAppDispatch, usePaneInstance } from "../../state/app-context";
7
8
  import { useViewport } from "../../react/input";
8
9
  import { padTo } from "../../utils/format";
9
- import { useRafCallback } from "../../react/use-raf-callback";
10
10
  import { measurePerf } from "../../utils/perf-marks";
11
11
  import { useDoubleClickActivation } from "../use-double-click-activation";
12
+ import { useScrollBoxScrollActivity } from "../table-view-shared";
12
13
  import { EmptyState } from "./status";
13
14
  import {
14
15
  expandTableColumns,
@@ -155,9 +156,11 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
155
156
  scrollToIndexAlign = "nearest",
156
157
  scrollToIndexVersion = 0,
157
158
  }: DataTableProps<T, C>) {
159
+ useThemeColors();
158
160
  const dispatch = useAppDispatch();
159
161
  const paneInstanceId = usePaneInstance()?.instanceId ?? null;
160
162
  const appViewport = useViewport();
163
+ const nativeRenderer = useNativeRenderer();
161
164
  const [scrollVersion, setScrollVersion] = useState(0);
162
165
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
163
166
  const measuredViewportHeight = scrollRef.current?.viewport?.height;
@@ -228,9 +231,13 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
228
231
  setScrollVersion((current) => current + 1);
229
232
  }
230
233
  onBodyScrollActivity();
231
- }, [onBodyScrollActivity, virtualize]);
232
- const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
233
- const scheduleHeaderScrollSync = useRafCallback(syncHeaderScroll);
234
+ nativeRenderer.requestRender();
235
+ }, [nativeRenderer, onBodyScrollActivity, virtualize]);
236
+ useScrollBoxScrollActivity({
237
+ scrollRef,
238
+ onVerticalScroll: handleBodyScrollActivity,
239
+ onHorizontalScroll: syncHeaderScroll,
240
+ });
234
241
  const focusPane = useCallback(() => {
235
242
  if (!paneInstanceId) return;
236
243
  dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
@@ -376,11 +383,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
376
383
  focusable={false}
377
384
  onMouseDown={() => {
378
385
  focusPane();
379
- scheduleHeaderScrollSync();
380
386
  }}
381
- onMouseUp={scheduleBodyScrollActivity}
382
- onMouseDrag={scheduleBodyScrollActivity}
383
- onMouseScroll={scheduleBodyScrollActivity}
384
387
  onSizeChange={measureContentWidth}
385
388
  >
386
389
  {items.length === 0 ? (
@@ -2,6 +2,7 @@ import { Box, Text, useUiHost } from "../../ui";
2
2
  import { TextAttributes } from "../../ui";
3
3
  import { type ComponentType, type ReactNode } from "react";
4
4
  import { colors } from "../../theme/colors";
5
+ import { useThemeColors } from "../../theme/theme-context";
5
6
 
6
7
  export interface DialogFrameProps {
7
8
  title: string;
@@ -11,6 +12,7 @@ export interface DialogFrameProps {
11
12
  }
12
13
 
13
14
  export function DialogFrame({ title, children, footer, showTitleDivider = true }: DialogFrameProps) {
15
+ useThemeColors();
14
16
  const HostDialogFrame = useUiHost().DialogFrame as ComponentType<DialogFrameProps> | undefined;
15
17
  if (HostDialogFrame) {
16
18
  return (
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { getTableWidth, hasMeaningfulTableHorizontalOverflow } from "./table-layout";
2
+ import {
3
+ buildTableGridTemplateColumns,
4
+ getTableWidth,
5
+ hasMeaningfulTableHorizontalOverflow,
6
+ } from "./table-layout";
3
7
 
4
8
  describe("table layout", () => {
5
9
  test("detects only meaningful horizontal table overflow", () => {
@@ -13,4 +17,26 @@ describe("table layout", () => {
13
17
  expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 1)).toBe(false);
14
18
  expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 2)).toBe(true);
15
19
  });
20
+
21
+ test("keeps non-flex web table columns fixed", () => {
22
+ const template = buildTableGridTemplateColumns([
23
+ { width: 4, align: "right" },
24
+ { width: 40, flexGrow: 1 },
25
+ { width: 24 },
26
+ { width: 5, align: "right" },
27
+ ]);
28
+
29
+ expect(template).toBe(
30
+ "minmax(4ch, 4ch) minmax(14ch, 40fr) minmax(8ch, 24ch) minmax(5ch, 5ch)",
31
+ );
32
+ });
33
+
34
+ test("keeps proportional web table columns when no column is flexible", () => {
35
+ const template = buildTableGridTemplateColumns([
36
+ { width: 12 },
37
+ { width: 8, align: "right" },
38
+ ]);
39
+
40
+ expect(template).toBe("minmax(8ch, 12fr) minmax(8ch, 8fr)");
41
+ });
16
42
  });
@@ -1,9 +1,10 @@
1
1
  import { useCallback, useEffect, useState, type RefObject } from "react";
2
2
  import type { ScrollBoxRenderable } from "../../ui";
3
3
 
4
- interface TableWidthColumn {
4
+ export interface TableWidthColumn {
5
5
  width: number;
6
6
  flexGrow?: number;
7
+ align?: string;
7
8
  }
8
9
 
9
10
  const TRAILING_COLUMN_GUTTER_WIDTH = 1;
@@ -32,6 +33,43 @@ export function expandTableColumns<C extends TableWidthColumn>(
32
33
  });
33
34
  }
34
35
 
36
+ function normalizedColumnWidth(column: TableWidthColumn): number {
37
+ return Math.max(1, Math.floor(column.width));
38
+ }
39
+
40
+ function columnMinCh(column: TableWidthColumn): number {
41
+ const width = normalizedColumnWidth(column);
42
+ if ((column.flexGrow ?? 0) > 0) {
43
+ return Math.max(8, Math.min(18, Math.floor(width * 0.35)));
44
+ }
45
+ if (column.align === "right") {
46
+ return Math.max(3, Math.min(width, 8));
47
+ }
48
+ if (width >= 16) {
49
+ return Math.max(8, Math.min(16, Math.floor(width * 0.35)));
50
+ }
51
+ return Math.max(1, Math.min(width, 8));
52
+ }
53
+
54
+ function columnFlexWeight(column: TableWidthColumn): number {
55
+ const flexGrow = column.flexGrow ?? 0;
56
+ const baseWeight = normalizedColumnWidth(column);
57
+ return flexGrow > 0 ? baseWeight * Math.max(1, flexGrow) : baseWeight;
58
+ }
59
+
60
+ export function buildTableGridTemplateColumns(columns: readonly TableWidthColumn[]): string {
61
+ const hasFlexColumn = columns.some((column) => (column.flexGrow ?? 0) > 0);
62
+ return columns
63
+ .map((column) => {
64
+ const width = normalizedColumnWidth(column);
65
+ if (!hasFlexColumn || (column.flexGrow ?? 0) > 0) {
66
+ return `minmax(${columnMinCh(column)}ch, ${columnFlexWeight(column)}fr)`;
67
+ }
68
+ return `minmax(${columnMinCh(column)}ch, ${width}ch)`;
69
+ })
70
+ .join(" ");
71
+ }
72
+
35
73
  export function useMeasuredTableContentWidth(
36
74
  tableWidth: number,
37
75
  headerScrollRef: RefObject<ScrollBoxRenderable | null> | undefined,
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useShortcut } from "../../react/input";
2
3
  import { Box, ScrollBox, Text, useUiHost } from "../../ui";
3
4
  import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
4
5
  import { colors, hoverBg } from "../../theme/colors";
@@ -21,6 +22,8 @@ export interface TabsProps {
21
22
  closeMode?: "active" | "always";
22
23
  addLabel?: string;
23
24
  onAdd?: () => void;
25
+ focused?: boolean;
26
+ keyboardNavigation?: boolean;
24
27
  }
25
28
 
26
29
  const WHEEL_DELTA_PER_CELL = 8;
@@ -34,6 +37,8 @@ export function Tabs({
34
37
  closeMode = "always",
35
38
  addLabel = "+",
36
39
  onAdd,
40
+ focused = false,
41
+ keyboardNavigation = true,
37
42
  }: TabsProps) {
38
43
  const ui = useUiHost();
39
44
  const NativeTabs = ui.Tabs;
@@ -51,6 +56,34 @@ export function Tabs({
51
56
  closeFg: colors.textMuted,
52
57
  addFg: colors.textMuted,
53
58
  };
59
+ const selectAdjacentTab = useCallback((direction: -1 | 1) => {
60
+ const enabledTabs = tabs.filter((tab) => !tab.disabled);
61
+ if (enabledTabs.length === 0) return;
62
+
63
+ const activeIndex = enabledTabs.findIndex((tab) => tab.value === activeValue);
64
+ const nextIndex = activeIndex >= 0
65
+ ? Math.max(0, Math.min(activeIndex + direction, enabledTabs.length - 1))
66
+ : direction > 0 ? 0 : enabledTabs.length - 1;
67
+ const nextTab = enabledTabs[nextIndex];
68
+ if (!nextTab || nextTab.value === activeValue) return;
69
+ onSelect(nextTab.value);
70
+ }, [activeValue, onSelect, tabs]);
71
+
72
+ useShortcut((event) => {
73
+ if (event.ctrl || event.meta || event.alt || event.targetEditable) return;
74
+
75
+ if (event.name === "h" || event.name === "left") {
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ selectAdjacentTab(-1);
79
+ return;
80
+ }
81
+ if (event.name === "l" || event.name === "right") {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ selectAdjacentTab(1);
85
+ }
86
+ }, { enabled: focused && keyboardNavigation });
54
87
 
55
88
  if (NativeTabs) {
56
89
  return (
@@ -179,6 +179,15 @@ function DataTableVirtualizationHarness() {
179
179
  name: `Row ${index}`,
180
180
  }));
181
181
 
182
+ useEffect(() => {
183
+ tableScrollBoxForTest = scrollRef.current;
184
+ return () => {
185
+ if (tableScrollBoxForTest === scrollRef.current) {
186
+ tableScrollBoxForTest = null;
187
+ }
188
+ };
189
+ });
190
+
182
191
  return (
183
192
  <DataTable
184
193
  columns={[{ id: "name", label: "NAME", width: 12, align: "left" }]}
@@ -333,6 +342,46 @@ describe("shared UI kit", () => {
333
342
  expect(frame).toContain("Insider");
334
343
  });
335
344
 
345
+ test("moves focused tabs with arrow keys and leaves Tab for pane focus", async () => {
346
+ let lastSelected = "overview";
347
+
348
+ function KeyboardTabsHarness() {
349
+ const [activeValue, setActiveValue] = useState("overview");
350
+ return (
351
+ <Tabs
352
+ tabs={[
353
+ { label: "Overview", value: "overview" },
354
+ { label: "News", value: "news" },
355
+ { label: "Chart", value: "chart" },
356
+ ]}
357
+ activeValue={activeValue}
358
+ onSelect={(value) => {
359
+ lastSelected = value;
360
+ setActiveValue(value);
361
+ }}
362
+ focused
363
+ />
364
+ );
365
+ }
366
+
367
+ testSetup = await testRender(<KeyboardTabsHarness />, { width: 40, height: 4 });
368
+
369
+ await act(async () => {
370
+ await testSetup!.renderOnce();
371
+ testSetup!.mockInput.pressArrow("right");
372
+ await testSetup!.renderOnce();
373
+ });
374
+
375
+ expect(lastSelected).toBe("news");
376
+
377
+ await act(async () => {
378
+ testSetup!.mockInput.pressTab();
379
+ await testSetup!.renderOnce();
380
+ });
381
+
382
+ expect(lastSelected).toBe("news");
383
+ });
384
+
336
385
  test("renders tab actions for editable tab sets", async () => {
337
386
  testSetup = await testRender(
338
387
  <Tabs
@@ -747,8 +796,9 @@ describe("shared UI kit", () => {
747
796
  expect(tableScrollBoxForTest?.verticalScrollBar.visible).toBe(true);
748
797
  });
749
798
 
750
- test("virtualizes data table rows by default", async () => {
799
+ test("virtualizes data table rows and refreshes after wheel scrolling", async () => {
751
800
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
801
+ tableScrollBoxForTest = null;
752
802
  testSetup = await testRender(
753
803
  <AppContext value={{ state, dispatch: () => {} }}>
754
804
  <PaneInstanceProvider paneId="portfolio-list:main">
@@ -765,5 +815,22 @@ describe("shared UI kit", () => {
765
815
  const frame = testSetup.captureCharFrame();
766
816
  expect(frame).toContain("Row 0");
767
817
  expect(frame).not.toContain("Row 99");
818
+ expect(tableScrollBoxForTest?.scrollTop).toBe(0);
819
+
820
+ await act(async () => {
821
+ for (let index = 0; index < 12; index++) {
822
+ await testSetup!.mockMouse.scroll(2, 2, "down");
823
+ }
824
+ await Promise.resolve();
825
+ await new Promise((resolve) => setTimeout(resolve, 0));
826
+ await testSetup!.renderOnce();
827
+ });
828
+ await testSetup.renderOnce();
829
+
830
+ const scrollTop = tableScrollBoxForTest?.scrollTop ?? 0;
831
+ expect(scrollTop).toBeGreaterThan(0);
832
+
833
+ const scrolledFrame = testSetup.captureCharFrame();
834
+ expect(scrolledFrame).toContain(`Row ${scrollTop}`);
768
835
  });
769
836
  });
@@ -318,23 +318,13 @@ function getTopFloatingPaneId(layout: LayoutConfig): string | null {
318
318
  return topInstanceId;
319
319
  }
320
320
 
321
- function resolveFocusedPaneId(
322
- previousLayout: LayoutConfig,
323
- nextLayout: LayoutConfig,
324
- focusedPaneId: string | null,
325
- ): string | null {
321
+ function resolveFocusedPaneId(nextLayout: LayoutConfig, focusedPaneId: string | null): string | null {
326
322
  const paneOrder = getPaneOrder(nextLayout);
327
323
  if (paneOrder.length === 0) return null;
328
324
  if (focusedPaneId && paneOrder.includes(focusedPaneId)) {
329
325
  return focusedPaneId;
330
326
  }
331
- const previouslyFloating = focusedPaneId
332
- ? previousLayout.floating.some((entry) => entry.instanceId === focusedPaneId)
333
- : false;
334
- if (previouslyFloating) {
335
- return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
336
- }
337
- return paneOrder[0] ?? null;
327
+ return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
338
328
  }
339
329
 
340
330
  function getPanelFocusTarget(layout: LayoutConfig, panel: "left" | "right"): string | null {
@@ -446,7 +436,7 @@ function withFocusedPane(state: AppState, config: AppConfig): AppState {
446
436
  layouts: syncLayouts(config.layouts, config.activeLayoutIndex, normalizedLayout),
447
437
  };
448
438
  const nextPaneState = reconcilePaneState(nextConfig, state.paneState);
449
- const focusedPaneId = resolveFocusedPaneId(state.config.layout, nextConfig.layout, state.focusedPaneId);
439
+ const focusedPaneId = resolveFocusedPaneId(nextConfig.layout, state.focusedPaneId);
450
440
  const focusedConfig = bringFocusedFloatingPaneToFront(nextConfig, focusedPaneId);
451
441
  return {
452
442
  ...state,
@@ -854,7 +844,7 @@ export function appReducer(state: AppState, action: AppAction): AppState {
854
844
 
855
845
  export function createInitialState(config: AppConfig, sessionSnapshot: AppSessionSnapshot | null = null): AppState {
856
846
  const paneState = reconcilePaneState(config, sessionSnapshot?.paneState ?? {});
857
- const defaultFocusedPaneId = getPaneOrder(config.layout)[0] ?? null;
847
+ const defaultFocusedPaneId = getTopFloatingPaneId(config.layout) ?? getPaneOrder(config.layout)[0] ?? null;
858
848
  const focusedPaneId = sessionSnapshot?.focusedPaneId
859
849
  && config.layout.instances.some((instance) => instance.instanceId === sessionSnapshot.focusedPaneId)
860
850
  ? sessionSnapshot.focusedPaneId
@@ -632,4 +632,79 @@ describe("MarketDataCoordinator", () => {
632
632
  expect(financials?.quote?.marketCap).toBe(3_640_775_908_600);
633
633
  expect(financials?.fundamentals?.trailingPE).toBe(31.4);
634
634
  });
635
+
636
+ it("batch loads only missing quotes when fresh quote data is already stored", async () => {
637
+ const batchTargets: QuoteSubscriptionTarget[][] = [];
638
+ const provider = createProvider({
639
+ getQuote: async () => ({
640
+ symbol: "AAPL",
641
+ price: 100,
642
+ currency: "USD",
643
+ change: 0,
644
+ changePercent: 0,
645
+ lastUpdated: 1_700_000_000_000,
646
+ }),
647
+ getQuotesBatch: async (targets) => {
648
+ batchTargets.push(targets);
649
+ return targets.map((target) => ({
650
+ target,
651
+ quote: {
652
+ symbol: target.symbol,
653
+ price: target.symbol === "MSFT" ? 200 : 300,
654
+ currency: "USD",
655
+ change: 0,
656
+ changePercent: 0,
657
+ lastUpdated: 1_700_000_000_000,
658
+ },
659
+ }));
660
+ },
661
+ });
662
+ const coordinator = new MarketDataCoordinator(provider);
663
+ const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
664
+ const msft = { symbol: "MSFT", exchange: "NASDAQ" };
665
+
666
+ await coordinator.loadQuote(aapl);
667
+ await coordinator.loadQuotesBatch([aapl, msft]);
668
+
669
+ expect(batchTargets).toHaveLength(1);
670
+ expect(batchTargets[0]?.map((target) => target.symbol)).toEqual(["MSFT"]);
671
+ expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(100);
672
+ expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
673
+ });
674
+
675
+ it("batch loads snapshots through provider batch support", async () => {
676
+ const batchSymbols: string[][] = [];
677
+ const provider = createProvider({
678
+ getTickerFinancialsBatch: async (targets) => {
679
+ batchSymbols.push(targets.map((target) => target.symbol));
680
+ return targets.map((target) => ({
681
+ target,
682
+ financials: {
683
+ quote: {
684
+ symbol: target.symbol,
685
+ price: target.symbol === "AAPL" ? 150 : 250,
686
+ currency: "USD",
687
+ change: 0,
688
+ changePercent: 0,
689
+ lastUpdated: 1_700_000_000_000,
690
+ },
691
+ fundamentals: { trailingPE: target.symbol === "AAPL" ? 20 : 30 },
692
+ annualStatements: [],
693
+ quarterlyStatements: [],
694
+ priceHistory: [],
695
+ },
696
+ }));
697
+ },
698
+ });
699
+ const coordinator = new MarketDataCoordinator(provider);
700
+
701
+ await coordinator.loadSnapshotsBatch([
702
+ { symbol: "AAPL", exchange: "NASDAQ" },
703
+ { symbol: "MSFT", exchange: "NASDAQ" },
704
+ ]);
705
+
706
+ expect(batchSymbols).toEqual([["AAPL", "MSFT"]]);
707
+ expect(coordinator.getTickerFinancialsSync({ symbol: "AAPL", exchange: "NASDAQ" })?.quote?.price).toBe(150);
708
+ expect(coordinator.getTickerFinancialsSync({ symbol: "MSFT", exchange: "NASDAQ" })?.fundamentals?.trailingPE).toBe(30);
709
+ });
635
710
  });