gloomberb 0.6.1 → 0.7.1

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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,18 +1,20 @@
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,
15
16
  getTableWidth,
17
+ hasMeaningfulTableHorizontalOverflow,
16
18
  tableContentWidthProps,
17
19
  useMeasuredTableContentWidth,
18
20
  } from "./table-layout";
@@ -154,9 +156,11 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
154
156
  scrollToIndexAlign = "nearest",
155
157
  scrollToIndexVersion = 0,
156
158
  }: DataTableProps<T, C>) {
159
+ useThemeColors();
157
160
  const dispatch = useAppDispatch();
158
161
  const paneInstanceId = usePaneInstance()?.instanceId ?? null;
159
162
  const appViewport = useViewport();
163
+ const nativeRenderer = useNativeRenderer();
160
164
  const [scrollVersion, setScrollVersion] = useState(0);
161
165
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
162
166
  const measuredViewportHeight = scrollRef.current?.viewport?.height;
@@ -199,10 +203,16 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
199
203
  ],
200
204
  );
201
205
  const tableWidth = useMemo(() => getTableWidth(columns), [columns]);
202
- const { contentWidth, measureContentWidth } = useMeasuredTableContentWidth(tableWidth, headerScrollRef, scrollRef);
206
+ const {
207
+ contentWidth: measuredContentWidth,
208
+ viewportWidth: measuredViewportWidth,
209
+ measureContentWidth,
210
+ } = useMeasuredTableContentWidth(tableWidth, headerScrollRef, scrollRef);
211
+ const horizontalScrollbarVisible = showHorizontalScrollbar
212
+ && hasMeaningfulTableHorizontalOverflow(tableWidth, measuredViewportWidth);
203
213
  const displayColumns = useMemo(
204
- () => expandTableColumns(columns, contentWidth),
205
- [columns, contentWidth],
214
+ () => expandTableColumns(columns, measuredContentWidth),
215
+ [columns, measuredContentWidth],
206
216
  );
207
217
  const handleRowMouseDown =
208
218
  useDoubleClickActivation<DataTableRowPointerTarget<T>>({
@@ -221,9 +231,13 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
221
231
  setScrollVersion((current) => current + 1);
222
232
  }
223
233
  onBodyScrollActivity();
224
- }, [onBodyScrollActivity, virtualize]);
225
- const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
226
- const scheduleHeaderScrollSync = useRafCallback(syncHeaderScroll);
234
+ nativeRenderer.requestRender();
235
+ }, [nativeRenderer, onBodyScrollActivity, virtualize]);
236
+ useScrollBoxScrollActivity({
237
+ scrollRef,
238
+ onVerticalScroll: handleBodyScrollActivity,
239
+ onHorizontalScroll: syncHeaderScroll,
240
+ });
227
241
  const focusPane = useCallback(() => {
228
242
  if (!paneInstanceId) return;
229
243
  dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
@@ -269,17 +283,18 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
269
283
  useEffect(() => {
270
284
  if (headerScrollRef.current) {
271
285
  headerScrollRef.current.horizontalScrollBar.visible = false;
272
- if (!showHorizontalScrollbar) {
286
+ if (!horizontalScrollbarVisible) {
273
287
  headerScrollRef.current.scrollLeft = 0;
274
288
  }
275
289
  }
276
290
  if (scrollRef.current) {
277
- scrollRef.current.horizontalScrollBar.visible = showHorizontalScrollbar;
278
- if (!showHorizontalScrollbar) {
291
+ scrollRef.current.horizontalScrollBar.visible = horizontalScrollbarVisible;
292
+ scrollRef.current.verticalScrollBar.visible = items.length > scrollRef.current.viewport.height;
293
+ if (!horizontalScrollbarVisible) {
279
294
  scrollRef.current.scrollLeft = 0;
280
295
  }
281
296
  }
282
- }, [columns.length, headerScrollRef, items.length, scrollRef, showHorizontalScrollbar]);
297
+ }, [columns.length, headerScrollRef, horizontalScrollbarVisible, items.length, measuredViewportHeight, scrollRef]);
283
298
 
284
299
  useEffect(() => {
285
300
  if (applyScrollToIndex()) return;
@@ -318,7 +333,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
318
333
  <Box
319
334
  flexDirection="row"
320
335
  height={1}
321
- {...tableContentWidthProps(contentWidth)}
336
+ {...tableContentWidthProps(measuredContentWidth)}
322
337
  paddingX={1}
323
338
  backgroundColor={colors.panel}
324
339
  >
@@ -368,11 +383,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
368
383
  focusable={false}
369
384
  onMouseDown={() => {
370
385
  focusPane();
371
- scheduleHeaderScrollSync();
372
386
  }}
373
- onMouseUp={scheduleBodyScrollActivity}
374
- onMouseDrag={scheduleBodyScrollActivity}
375
- onMouseScroll={scheduleBodyScrollActivity}
376
387
  onSizeChange={measureContentWidth}
377
388
  >
378
389
  {items.length === 0 ? (
@@ -396,7 +407,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
396
407
  key={getItemKey(item, index)}
397
408
  flexDirection="row"
398
409
  height={1}
399
- {...tableContentWidthProps(contentWidth)}
410
+ {...tableContentWidthProps(measuredContentWidth)}
400
411
  paddingX={1}
401
412
  backgroundColor={sectionHeader.backgroundColor ?? colors.bg}
402
413
  onMouseDown={(event) => {
@@ -433,7 +444,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
433
444
  key={getItemKey(item, index)}
434
445
  flexDirection="row"
435
446
  height={1}
436
- {...tableContentWidthProps(contentWidth)}
447
+ {...tableContentWidthProps(measuredContentWidth)}
437
448
  paddingX={1}
438
449
  backgroundColor={rowBg}
439
450
  onMouseMove={() => {
@@ -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 (
@@ -6,7 +6,6 @@ export { DataTable } from "./data-table";
6
6
  export type { DataTableCell, DataTableColumn, DataTableProps } from "./data-table";
7
7
 
8
8
  export { Button } from "./button";
9
-
10
9
  export { ShortcutHint } from "./shortcut-hint";
11
10
 
12
11
  export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select-dialog";
@@ -1,8 +1,11 @@
1
1
  import { Box, Text, TextAttributes, useUiHost } from "../../ui";
2
2
  import { useShortcut } from "../../react/input";
3
- import { type ComponentType, type ReactNode } from "react";
3
+ import { useCallback, type ComponentType, type ReactNode } from "react";
4
4
  import { colors } from "../../theme/colors";
5
- import { isDetailBackNavigationKey } from "../../utils/back-navigation";
5
+ import {
6
+ isDetailBackNavigationKey,
7
+ isMouseBackNavigationEvent,
8
+ } from "../../utils/back-navigation";
6
9
 
7
10
  export interface PageStackViewProps {
8
11
  focused: boolean;
@@ -41,13 +44,29 @@ export function PageStackView({
41
44
  );
42
45
  }
43
46
 
47
+ const goBack = useCallback((event?: {
48
+ preventDefault?: () => void;
49
+ stopPropagation?: () => void;
50
+ }) => {
51
+ event?.stopPropagation?.();
52
+ event?.preventDefault?.();
53
+ onBack();
54
+ }, [onBack]);
55
+
44
56
  useShortcut((event) => {
45
57
  if (!focused || !detailOpen || !isDetailBackNavigationKey(event)) return;
46
- event.stopPropagation?.();
47
- event.preventDefault?.();
48
- onBack();
58
+ goBack(event);
49
59
  });
50
60
 
61
+ const handleMouseDown = useCallback((event: {
62
+ button?: unknown;
63
+ preventDefault?: () => void;
64
+ stopPropagation?: () => void;
65
+ }) => {
66
+ if (!focused || !detailOpen || !isMouseBackNavigationEvent(event)) return;
67
+ goBack(event);
68
+ }, [detailOpen, focused, goBack]);
69
+
51
70
  if (!detailOpen) {
52
71
  return (
53
72
  <Box flexDirection="column" flexGrow={1} overflow="hidden">
@@ -57,16 +76,26 @@ export function PageStackView({
57
76
  }
58
77
 
59
78
  return (
60
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
79
+ <Box
80
+ flexDirection="column"
81
+ flexGrow={1}
82
+ overflow="hidden"
83
+ onMouseDown={handleMouseDown}
84
+ >
61
85
  <Box height={1} flexDirection="row">
62
86
  <Box
63
87
  onMouseDown={(event) => {
64
- event.preventDefault?.();
65
- event.stopPropagation?.();
66
- onBack();
88
+ goBack(event);
67
89
  }}
90
+ backgroundColor={colors.selected}
68
91
  >
69
- <Text fg={colors.textBright}>{`← ${backLabel}`}</Text>
92
+ <Text
93
+ fg={colors.selectedText}
94
+ bg={colors.selected}
95
+ attributes={TextAttributes.BOLD}
96
+ >
97
+ {`← ${backLabel}`}
98
+ </Text>
70
99
  </Box>
71
100
  {detailTitle ? (
72
101
  <>
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildTableGridTemplateColumns,
4
+ getTableWidth,
5
+ hasMeaningfulTableHorizontalOverflow,
6
+ } from "./table-layout";
7
+
8
+ describe("table layout", () => {
9
+ test("detects only meaningful horizontal table overflow", () => {
10
+ const tableWidth = getTableWidth([
11
+ { width: 12 },
12
+ { width: 8 },
13
+ ]);
14
+
15
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, 0)).toBe(false);
16
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth)).toBe(false);
17
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 1)).toBe(false);
18
+ expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 2)).toBe(true);
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
+ });
42
+ });
@@ -1,15 +1,22 @@
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
 
10
+ const TRAILING_COLUMN_GUTTER_WIDTH = 1;
11
+
9
12
  export function getTableWidth(columns: readonly TableWidthColumn[]): number {
10
13
  return columns.reduce((sum, column) => sum + column.width + 1, 2);
11
14
  }
12
15
 
16
+ export function hasMeaningfulTableHorizontalOverflow(tableWidth: number, viewportWidth: number): boolean {
17
+ return viewportWidth > 0 && tableWidth - viewportWidth > TRAILING_COLUMN_GUTTER_WIDTH;
18
+ }
19
+
13
20
  export function expandTableColumns<C extends TableWidthColumn>(
14
21
  columns: readonly C[],
15
22
  targetWidth: number,
@@ -26,6 +33,43 @@ export function expandTableColumns<C extends TableWidthColumn>(
26
33
  });
27
34
  }
28
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
+
29
73
  export function useMeasuredTableContentWidth(
30
74
  tableWidth: number,
31
75
  headerScrollRef: RefObject<ScrollBoxRenderable | null> | undefined,
@@ -34,9 +78,9 @@ export function useMeasuredTableContentWidth(
34
78
  const [viewportWidth, setViewportWidth] = useState(0);
35
79
 
36
80
  const measureContentWidth = useCallback(() => {
37
- const nextWidth = scrollRef?.current?.viewport?.width
38
- ?? headerScrollRef?.current?.viewport?.width
39
- ?? 0;
81
+ const bodyWidth = scrollRef?.current?.viewport?.width || scrollRef?.current?.width || 0;
82
+ const headerWidth = headerScrollRef?.current?.viewport?.width || headerScrollRef?.current?.width || 0;
83
+ const nextWidth = bodyWidth || headerWidth;
40
84
  setViewportWidth((current) => current === nextWidth ? current : nextWidth);
41
85
  }, [headerScrollRef, scrollRef]);
42
86
  const scheduleContentWidthMeasure = useCallback(() => {
@@ -48,6 +92,7 @@ export function useMeasuredTableContentWidth(
48
92
 
49
93
  return {
50
94
  contentWidth: Math.max(tableWidth, viewportWidth),
95
+ viewportWidth,
51
96
  measureContentWidth: scheduleContentWidthMeasure,
52
97
  };
53
98
  }
@@ -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 (