gloomberb 0.6.0 → 0.7.0

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 (156) hide show
  1. package/package.json +2 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +62 -77
  14. package/src/components/chart/native/kitty.test.ts +23 -0
  15. package/src/components/chart/native/surface-visibility.ts +48 -0
  16. package/src/components/chart/static-chart-surface.tsx +28 -4
  17. package/src/components/chart/stock-chart.test.ts +0 -16
  18. package/src/components/chart/stock-chart.tsx +52 -71
  19. package/src/components/chart/time-axis-label.tsx +43 -0
  20. package/src/components/command-bar/command-bar.test.tsx +71 -8
  21. package/src/components/command-bar/command-bar.tsx +388 -713
  22. package/src/components/command-bar/confirm-body.tsx +51 -0
  23. package/src/components/command-bar/list-model.ts +77 -0
  24. package/src/components/command-bar/view-model.ts +0 -19
  25. package/src/components/command-bar/workflow-ops.ts +4 -0
  26. package/src/components/data-table-stack-view.test.tsx +39 -4
  27. package/src/components/data-table-stack-view.tsx +11 -104
  28. package/src/components/data-table-view.tsx +43 -79
  29. package/src/components/feed-data-table-stack-view.tsx +10 -17
  30. package/src/components/feed-stack-controller.ts +39 -0
  31. package/src/components/index.ts +6 -2
  32. package/src/components/layout/floating-pane.tsx +30 -2
  33. package/src/components/layout/header.tsx +10 -7
  34. package/src/components/layout/pane-content.tsx +2 -0
  35. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  36. package/src/components/layout/pane-cycle-order.ts +32 -0
  37. package/src/components/layout/pane-footer.tsx +26 -4
  38. package/src/components/layout/pane-header.tsx +5 -5
  39. package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
  40. package/src/components/layout/shell.test.tsx +66 -22
  41. package/src/components/layout/shell.tsx +13 -6
  42. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  43. package/src/components/pane-settings-dialog.tsx +12 -4
  44. package/src/components/pane-template-wizard.test.tsx +3 -4
  45. package/src/components/speedometer-gauge.test.tsx +45 -0
  46. package/src/components/speedometer-gauge.tsx +558 -0
  47. package/src/components/table-view-shared.tsx +158 -0
  48. package/src/components/ticker-list-table-view.tsx +42 -72
  49. package/src/components/ui/confirm-dialog.tsx +66 -0
  50. package/src/components/ui/data-table.tsx +18 -10
  51. package/src/components/ui/index.ts +0 -1
  52. package/src/components/ui/page-stack-view.tsx +39 -10
  53. package/src/components/ui/remote-image.tsx +15 -40
  54. package/src/components/ui/table-layout.test.ts +16 -0
  55. package/src/components/ui/table-layout.ts +10 -3
  56. package/src/components/ui/ui.test.tsx +100 -20
  57. package/src/core/state/app-state.ts +6 -3
  58. package/src/market-data/coordinator.test.ts +1 -1
  59. package/src/market-data/coordinator.ts +0 -1
  60. package/src/news/aggregator.ts +0 -3
  61. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  62. package/src/plugins/builtin/ai/providers.ts +0 -17
  63. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  64. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  65. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  66. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  67. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  68. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  69. package/src/plugins/builtin/chat-controller.ts +75 -6
  70. package/src/plugins/builtin/chat.test.tsx +6 -6
  71. package/src/plugins/builtin/chat.tsx +29 -20
  72. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  73. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  74. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  75. package/src/plugins/builtin/correlation/index.tsx +1 -6
  76. package/src/plugins/builtin/debug.tsx +1 -3
  77. package/src/plugins/builtin/econ/index.tsx +1 -2
  78. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  79. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  80. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  82. package/src/plugins/builtin/help.test.tsx +2 -2
  83. package/src/plugins/builtin/help.tsx +2 -2
  84. package/src/plugins/builtin/holders/index.tsx +10 -15
  85. package/src/plugins/builtin/insider/index.tsx +22 -89
  86. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  87. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  88. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  89. package/src/plugins/builtin/news.tsx +4 -16
  90. package/src/plugins/builtin/notes.tsx +18 -55
  91. package/src/plugins/builtin/options.test.tsx +58 -2
  92. package/src/plugins/builtin/options.tsx +108 -49
  93. package/src/plugins/builtin/plugin-groups.ts +2 -0
  94. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  95. package/src/plugins/builtin/research/index.tsx +5 -21
  96. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  97. package/src/plugins/builtin/sec.tsx +13 -66
  98. package/src/plugins/builtin/sectors/index.tsx +1 -1
  99. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  100. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  102. package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
  103. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +69 -4
  104. package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
  105. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  106. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  107. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  108. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  109. package/src/plugins/ibkr/gateway-service.ts +1 -1
  110. package/src/plugins/ibkr/index.test.tsx +0 -2
  111. package/src/plugins/ibkr/trade-tab.test.tsx +56 -5
  112. package/src/plugins/ibkr/trade-tab.tsx +13 -11
  113. package/src/plugins/pane-manager.ts +0 -13
  114. package/src/plugins/plugin-runtime.test.tsx +6 -6
  115. package/src/plugins/plugin-runtime.tsx +13 -8
  116. package/src/plugins/prediction-markets/cli.ts +0 -1
  117. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  118. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  119. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  120. package/src/plugins/registry.ts +10 -39
  121. package/src/react/input.ts +11 -2
  122. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  123. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  124. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  125. package/src/renderers/electrobun/view/input-host.tsx +123 -12
  126. package/src/renderers/electrobun/view/key-event.ts +1 -1
  127. package/src/renderers/electrobun/view/styles.css +331 -0
  128. package/src/renderers/electrobun/view/ui-host.tsx +22 -3
  129. package/src/renderers/opentui/chart-surface.tsx +179 -0
  130. package/src/renderers/opentui/host.tsx +6 -0
  131. package/src/renderers/opentui/image-loader.test.ts +25 -0
  132. package/src/renderers/opentui/image-loader.ts +105 -0
  133. package/src/renderers/opentui/image-surface.tsx +266 -0
  134. package/src/renderers/opentui/input-host.tsx +45 -6
  135. package/src/renderers/opentui/start.tsx +48 -30
  136. package/src/renderers/opentui/test-utils.tsx +17 -0
  137. package/src/renderers/opentui/ui-host.tsx +7 -3
  138. package/src/sources/provider-router.ts +0 -4
  139. package/src/state/app-context.test.ts +45 -0
  140. package/src/state/app-context.tsx +4 -0
  141. package/src/state/pane-scroll-registry.tsx +254 -0
  142. package/src/state/use-inline-tickers.test.tsx +73 -0
  143. package/src/state/use-inline-tickers.ts +7 -1
  144. package/src/test-support/plugin-runtime.ts +3 -2
  145. package/src/theme/colors.ts +12 -3
  146. package/src/theme/themes.test.ts +23 -1
  147. package/src/theme/themes.ts +115 -0
  148. package/src/types/broker.ts +1 -1
  149. package/src/types/data-provider.ts +0 -1
  150. package/src/types/plugin.ts +0 -2
  151. package/src/ui/host.tsx +4 -0
  152. package/src/ui/index.tsx +9 -1
  153. package/src/utils/back-navigation.ts +24 -1
  154. package/src/version.ts +1 -1
  155. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  156. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,6 +1,8 @@
1
1
 
2
2
  export { PriceSelectorDialog } from "./price-selector-dialog";
3
3
  export { StaticChartSurface } from "./chart/static-chart-surface";
4
+ export { SpeedometerGauge } from "./speedometer-gauge";
5
+ export type { SpeedometerSegment } from "./speedometer-gauge";
4
6
  export type { QuoteFlashDirection } from "./ticker-list-table";
5
7
  export { TickerListTableView } from "./ticker-list-table-view";
6
8
  export type { TickerListVisibleRange } from "./ticker-list-table-view";
@@ -10,14 +12,16 @@ export type { DataTableKeyEvent } from "./data-table-view";
10
12
  export { DataTableStackView } from "./data-table-stack-view";
11
13
  export { FeedDataTableStackView } from "./feed-data-table-stack-view";
12
14
  export type { FeedDataTableItem } from "./feed-data-table-stack-view";
13
- export { usePaneFooter } from "./layout/pane-footer";
15
+ export { activeStackIndex, sortStackItems } from "./feed-stack-controller";
16
+ export type { StackSortPreference } from "./feed-stack-controller";
17
+ export { PaneFooterScope, usePaneFooter } from "./layout/pane-footer";
14
18
  export type { PaneFooterSegment, PaneHint } from "./layout/pane-footer";
15
19
  export { useExternalLinkFooter } from "./use-external-link-footer";
16
20
  export { Button } from "./ui/button";
21
+ export { ConfirmDialog } from "./ui/confirm-dialog";
17
22
  export { ChoiceDialog } from "./ui/choice-dialog";
18
23
  export type { ChoiceDialogChoice, ChoiceDialogProps } from "./ui/choice-dialog";
19
24
  export type { DataTableCell, DataTableColumn } from "./ui/data-table";
20
- export { DialogFrame } from "./ui/frame";
21
25
  export { EmptyState } from "./ui/status";
22
26
  export { getMessageComposerBlockHeight, MessageComposer } from "./ui/message-composer";
23
27
  export { NumberField, TextField } from "./ui/fields";
@@ -30,6 +30,24 @@ interface FloatingPaneWrapperProps {
30
30
  children: ReactNode;
31
31
  }
32
32
 
33
+ function TerminalFloatingPaneBorder({ width, height }: { width: number; height: number }) {
34
+ const borderWidth = Math.max(0, Math.floor(width));
35
+ const borderHeight = Math.max(0, Math.floor(height));
36
+ const bodyHeight = Math.max(0, borderHeight - 2);
37
+ if (borderWidth < 2 || bodyHeight <= 0) return null;
38
+
39
+ return (
40
+ <>
41
+ <Box position="absolute" top={1} left={0} width={1} height={bodyHeight}>
42
+ <Text fg={colors.border} selectable={false}>{"│".repeat(bodyHeight)}</Text>
43
+ </Box>
44
+ <Box position="absolute" top={1} left={borderWidth - 1} width={1} height={bodyHeight}>
45
+ <Text fg={colors.border} selectable={false}>{"│".repeat(bodyHeight)}</Text>
46
+ </Box>
47
+ </>
48
+ );
49
+ }
50
+
33
51
  /** Pure visual wrapper; Shell owns the interaction state and supplies handlers. */
34
52
  export function FloatingPaneWrapper({
35
53
  paneId,
@@ -101,7 +119,17 @@ export function FloatingPaneWrapper({
101
119
  {children}
102
120
  </Box>
103
121
 
104
- {reserveFooter && <PaneFooterBar footer={footer} focused={focused} width={width} reserveRight={2} />}
122
+ {reserveFooter && (
123
+ <PaneFooterBar
124
+ footer={footer}
125
+ focused={focused}
126
+ width={width}
127
+ reserveRight={2}
128
+ showBorder={!nativePaneChrome && !focused}
129
+ />
130
+ )}
131
+
132
+ {!nativePaneChrome && !focused && <TerminalFloatingPaneBorder width={width} height={height} />}
105
133
 
106
134
  {nativePaneChrome ? (
107
135
  <Box
@@ -117,7 +145,7 @@ export function FloatingPaneWrapper({
117
145
  />
118
146
  ) : (
119
147
  <Box position="absolute" bottom={0} right={0} width={2} height={1}>
120
- <Text fg={focused ? colors.borderFocused : colors.textDim} selectable={false}>{focused ? "─◢" : " ◢"}</Text>
148
+ <Text fg={focused ? colors.borderFocused : colors.border} selectable={false}>{"─◢"}</Text>
121
149
  </Box>
122
150
  )}
123
151
  </Box>
@@ -1,6 +1,6 @@
1
1
  import { Box, SpinnerMark, Text, TextAttributes, useUiCapabilities } from "../../ui";
2
2
  import { useEffect, type ReactNode } from "react";
3
- import { colors, priceColor } from "../../theme/colors";
3
+ import { blendHex, colors, priceColor } from "../../theme/colors";
4
4
  import { useAppActive } from "../../state/app-activity";
5
5
  import { useAppDispatch, useAppSelector } from "../../state/app-context";
6
6
  import {
@@ -23,8 +23,8 @@ const UPDATE_NOTICE_DURATION_MS = 5_000;
23
23
 
24
24
  function DesktopHeaderPill({
25
25
  children,
26
- backgroundColor = "rgba(8, 12, 15, 0.34)",
27
- borderColor = "rgba(132, 145, 161, 0.22)",
26
+ backgroundColor = blendHex(colors.header, colors.bg, 0.28),
27
+ borderColor = blendHex(colors.border, colors.headerText, 0.22),
28
28
  }: {
29
29
  children: ReactNode;
30
30
  backgroundColor?: string;
@@ -173,8 +173,8 @@ export function Header() {
173
173
  Gloomberb
174
174
  </Text>
175
175
  <DesktopHeaderPill
176
- backgroundColor="rgba(84, 201, 159, 0.12)"
177
- borderColor="rgba(84, 201, 159, 0.28)"
176
+ backgroundColor={blendHex(colors.header, colors.headerText, 0.1)}
177
+ borderColor={blendHex(colors.border, colors.headerText, 0.28)}
178
178
  >
179
179
  <Text fg={colors.headerText} style={{ fontSize: 11 }}>v{VERSION}</Text>
180
180
  </DesktopHeaderPill>
@@ -184,8 +184,11 @@ export function Header() {
184
184
  </Box>
185
185
  {mktLabel ? (
186
186
  <Box paddingRight={1}>
187
- <DesktopHeaderPill borderColor="rgba(132, 145, 161, 0.18)">
188
- <Text fg={mktColor}>{mktLabel}</Text>
187
+ <DesktopHeaderPill
188
+ backgroundColor={blendHex(colors.header, mktColor, 0.12)}
189
+ borderColor={blendHex(colors.border, mktColor, 0.3)}
190
+ >
191
+ <Text fg={mktColor} style={{ fontSize: 11, fontWeight: 700 }}>{mktLabel}</Text>
189
192
  </DesktopHeaderPill>
190
193
  </Box>
191
194
  ) : null}
@@ -1,5 +1,6 @@
1
1
  import { memo, useCallback } from "react";
2
2
  import { PaneInstanceProvider } from "../../state/app-context";
3
+ import { PaneKeyboardScrollController } from "../../state/pane-scroll-registry";
3
4
  import type { PaneDef } from "../../types/plugin";
4
5
 
5
6
  interface PaneContentProps {
@@ -27,6 +28,7 @@ export const PaneContent = memo(function PaneContent({
27
28
 
28
29
  return (
29
30
  <PaneInstanceProvider paneId={paneId}>
31
+ <PaneKeyboardScrollController paneId={paneId} focused={focused} />
30
32
  <Component
31
33
  paneId={paneId}
32
34
  paneType={paneType}
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PluginRegistry } from "../../plugins/registry";
3
+ import { createDefaultConfig } from "../../types/config";
4
+ import { getVisiblePaneCycleOrder } from "./pane-cycle-order";
5
+
6
+ function createRegistry(options: {
7
+ paneIds: readonly string[];
8
+ disabledPaneIds?: Record<string, readonly string[]>;
9
+ }): PluginRegistry {
10
+ return {
11
+ panes: new Map(options.paneIds.map((paneId) => [paneId, {}])),
12
+ getPluginPaneIds: (pluginId: string) => [...(options.disabledPaneIds?.[pluginId] ?? [])],
13
+ } as unknown as PluginRegistry;
14
+ }
15
+
16
+ describe("getVisiblePaneCycleOrder", () => {
17
+ test("skips disabled and unregistered panes when cycling focus", () => {
18
+ const config = createDefaultConfig("/tmp/gloomberb-pane-cycle-order");
19
+ const registry = createRegistry({
20
+ paneIds: ["portfolio-list", "chat", "ticker-detail"],
21
+ disabledPaneIds: { chat: ["chat"] },
22
+ });
23
+
24
+ expect(getVisiblePaneCycleOrder(config.layout, registry, ["chat"])).toEqual([
25
+ "portfolio-list:main",
26
+ "ticker-detail:main",
27
+ "ticker-detail:nvda",
28
+ ]);
29
+ });
30
+ });
@@ -0,0 +1,32 @@
1
+ import { getDockedPaneIds } from "../../plugins/pane-manager";
2
+ import type { PluginRegistry } from "../../plugins/registry";
3
+ import { findPaneInstance, type LayoutConfig } from "../../types/config";
4
+
5
+ function disabledPaneTypes(pluginRegistry: PluginRegistry, disabledPlugins: readonly string[]): Set<string> {
6
+ const paneTypes = new Set<string>();
7
+ for (const pluginId of disabledPlugins) {
8
+ for (const paneId of pluginRegistry.getPluginPaneIds(pluginId)) {
9
+ paneTypes.add(paneId);
10
+ }
11
+ }
12
+ return paneTypes;
13
+ }
14
+
15
+ export function getVisiblePaneCycleOrder(
16
+ layout: LayoutConfig,
17
+ pluginRegistry: PluginRegistry,
18
+ disabledPlugins: readonly string[],
19
+ ): string[] {
20
+ const disabledTypes = disabledPaneTypes(pluginRegistry, disabledPlugins);
21
+ const isVisiblePane = (instanceId: string) => {
22
+ const instance = findPaneInstance(layout, instanceId);
23
+ return !!instance
24
+ && !disabledTypes.has(instance.paneId)
25
+ && pluginRegistry.panes.has(instance.paneId);
26
+ };
27
+
28
+ return [
29
+ ...getDockedPaneIds(layout),
30
+ ...layout.floating.map((entry) => entry.instanceId),
31
+ ].filter(isVisiblePane);
32
+ }
@@ -158,6 +158,21 @@ export function PaneFooterProvider({
158
158
  );
159
159
  }
160
160
 
161
+ export function PaneFooterScope({
162
+ active,
163
+ children,
164
+ }: {
165
+ active: boolean;
166
+ children: ReactNode;
167
+ }) {
168
+ const context = useContext(PaneFooterContext);
169
+ return (
170
+ <PaneFooterContext.Provider value={active ? context : null}>
171
+ {children}
172
+ </PaneFooterContext.Provider>
173
+ );
174
+ }
175
+
161
176
  export function usePaneFooter(
162
177
  registrationId: string,
163
178
  factory: () => PaneFooterRegistration | null | undefined,
@@ -342,17 +357,24 @@ export function PaneFooterBar({
342
357
  focused,
343
358
  width = 0,
344
359
  reserveRight = 0,
360
+ showBorder = false,
345
361
  }: {
346
362
  footer?: CombinedPaneFooter | null;
347
363
  focused: boolean;
348
364
  width?: number;
349
365
  reserveRight?: number;
366
+ showBorder?: boolean;
350
367
  }) {
351
368
  const { nativePaneChrome } = useUiCapabilities();
352
369
  const resolvedFooter = footer ?? EMPTY_FOOTER;
353
370
  const empty = !hasPaneFooterContent(resolvedFooter);
354
371
  const borderColor = focused ? colors.borderFocused : colors.border;
355
372
  const topBorderColor = colors.border;
373
+ const nativeBackgroundColor = empty
374
+ ? "transparent"
375
+ : focused
376
+ ? blendHex(colors.bg, colors.borderFocused, 0.06)
377
+ : blendHex(colors.panel, colors.border, 0.12);
356
378
  const reservedRight = Math.max(0, reserveRight);
357
379
  const rightPadding = reservedRight > 0 ? reservedRight : 1;
358
380
 
@@ -370,7 +392,7 @@ export function PaneFooterBar({
370
392
  style={{
371
393
  "--pane-footer-border-color": empty ? "transparent" : topBorderColor,
372
394
  borderTop: `1px solid ${empty ? "transparent" : topBorderColor}`,
373
- backgroundColor: empty ? "transparent" : focused ? "rgba(84, 201, 159, 0.05)" : "rgba(20, 25, 30, 0.55)",
395
+ backgroundColor: nativeBackgroundColor,
374
396
  boxShadow: empty ? "none" : "inset 0 1px 0 rgba(255,255,255,0.03)",
375
397
  }}
376
398
  >
@@ -384,15 +406,15 @@ export function PaneFooterBar({
384
406
  );
385
407
  }
386
408
 
387
- if (focused) {
409
+ if (focused || showBorder) {
388
410
  const contentWidth = Math.max(0, Math.floor(width) - 1 - reservedRight - (reservedRight > 0 ? 0 : 1));
389
411
  return (
390
- <Box height={1} width={width} flexDirection="row" data-gloom-role="pane-footer" data-focused="true" data-empty={empty ? "true" : "false"}>
412
+ <Box height={1} width={width} flexDirection="row" data-gloom-role="pane-footer" data-focused={focused ? "true" : "false"} data-empty={empty ? "true" : "false"}>
391
413
  <Text fg={borderColor} selectable={false}>└</Text>
392
414
  <Box width={contentWidth} height={1} overflow="hidden">
393
415
  {empty
394
416
  ? <Text fg={borderColor} selectable={false}>{"─".repeat(contentWidth)}</Text>
395
- : <FooterContent footer={resolvedFooter} focused width={contentWidth} />}
417
+ : <FooterContent footer={resolvedFooter} focused={focused} width={contentWidth} />}
396
418
  </Box>
397
419
  {reservedRight === 0 && <Text fg={borderColor} selectable={false}>┘</Text>}
398
420
  </Box>
@@ -108,7 +108,6 @@ export function PaneHeader({
108
108
  const backgroundColor = floating ? floatingPaneTitleBg(focused) : paneTitleBg(focused);
109
109
  const actionText = showActions ? PANE_HEADER_ACTION : " ";
110
110
  const closeText = floating ? PANE_HEADER_CLOSE : "";
111
- const bc = colors.borderFocused;
112
111
  const textColor = paneTitleText(focused, floating);
113
112
 
114
113
  if (nativePaneChrome) {
@@ -184,9 +183,10 @@ export function PaneHeader({
184
183
  );
185
184
  }
186
185
 
187
- if (focused) {
186
+ if (focused || floating) {
188
187
  // Build: ┌─:: Title ─────────── ... x─┐
189
188
  // Reserve 2 for corners, 1 for ─ after ┌, 1 for ─ before ┐
189
+ const borderColor = focused ? colors.borderFocused : colors.border;
190
190
  const innerWidth = Math.max(0, width - 4);
191
191
  const contentWidth = PANE_HEADER_GRIP.length + closeText.length + actionText.length;
192
192
  const titleWidth = Math.max(0, innerWidth - contentWidth);
@@ -196,9 +196,9 @@ export function PaneHeader({
196
196
 
197
197
  return (
198
198
  <Box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
199
- <Text fg={bc} selectable={false}>{"┌─"}</Text>
199
+ <Text fg={borderColor} selectable={false}>{"┌─"}</Text>
200
200
  <Text fg={textColor} selectable={false}>{`${PANE_HEADER_GRIP}${clippedTitle}`}</Text>
201
- <Text fg={bc} selectable={false}>{fill}</Text>
201
+ <Text fg={borderColor} selectable={false}>{fill}</Text>
202
202
  <TerminalPaneButton
203
203
  text={actionText}
204
204
  fg={textColor}
@@ -213,7 +213,7 @@ export function PaneHeader({
213
213
  onMouseDown={onCloseMouseDown}
214
214
  />
215
215
  )}
216
- <Text fg={bc} selectable={false}>{"─┐"}</Text>
216
+ <Text fg={borderColor} selectable={false}>{"─┐"}</Text>
217
217
  </Box>
218
218
  );
219
219
  }
@@ -0,0 +1,226 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useReducer, type ReactNode } from "react";
3
+ import { testRender } from "../../renderers/opentui/test-utils";
4
+ import { useShortcut } from "../../react/input";
5
+ import { AppContext, appReducer, createInitialState } from "../../state/app-context";
6
+ import { Box, ScrollBox, Text, type ScrollBoxRenderable } from "../../ui";
7
+ import { DataTableStackView } from "../data-table-stack-view";
8
+ import { type DataTableColumn } from "../ui";
9
+ import { createDefaultConfig } from "../../types/config";
10
+ import type { PaneProps } from "../../types/plugin";
11
+ import { PaneContent } from "./pane-content";
12
+
13
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
14
+ let scrollRef: ScrollBoxRenderable | null = null;
15
+ let hiddenScrollRef: ScrollBoxRenderable | null = null;
16
+
17
+ afterEach(() => {
18
+ if (testSetup) {
19
+ act(() => {
20
+ testSetup!.renderer.destroy();
21
+ });
22
+ testSetup = undefined;
23
+ }
24
+ scrollRef = null;
25
+ hiddenScrollRef = null;
26
+ });
27
+
28
+ function emitDownArrow() {
29
+ return act(async () => {
30
+ testSetup!.mockInput.pressArrow("down");
31
+ await testSetup!.renderOnce();
32
+ });
33
+ }
34
+
35
+ async function renderHarness(component: ReactNode) {
36
+ await act(async () => {
37
+ testSetup = await testRender(component, { width: 30, height: 5 });
38
+ });
39
+ await act(async () => {
40
+ await testSetup!.renderOnce();
41
+ await testSetup!.renderOnce();
42
+ await testSetup!.renderOnce();
43
+ });
44
+ }
45
+
46
+ function LongScrollPane({ width, height }: PaneProps) {
47
+ return (
48
+ <Box width={width} height={height}>
49
+ <ScrollBox
50
+ ref={(node) => {
51
+ scrollRef = node;
52
+ }}
53
+ height={height}
54
+ scrollY
55
+ focusable={false}
56
+ >
57
+ <Box flexDirection="column">
58
+ {Array.from({ length: 20 }, (_, index) => (
59
+ <Box key={index} height={1}>
60
+ <Text>{`Row ${index}`}</Text>
61
+ </Box>
62
+ ))}
63
+ </Box>
64
+ </ScrollBox>
65
+ </Box>
66
+ );
67
+ }
68
+
69
+ function ConsumingScrollPane(props: PaneProps) {
70
+ useShortcut((event) => {
71
+ if (event.name !== "down") return;
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ });
75
+ return <LongScrollPane {...props} />;
76
+ }
77
+
78
+ function HiddenMountedTabPane({ width, height }: PaneProps) {
79
+ const renderRows = () => Array.from({ length: 20 }, (_, index) => (
80
+ <Box key={index} height={1}>
81
+ <Text>{`Tab row ${index}`}</Text>
82
+ </Box>
83
+ ));
84
+
85
+ return (
86
+ <Box width={width} height={height}>
87
+ <ScrollBox
88
+ ref={(node) => {
89
+ scrollRef = node;
90
+ }}
91
+ height={height}
92
+ scrollY
93
+ focusable={false}
94
+ >
95
+ <Box flexDirection="column">{renderRows()}</Box>
96
+ </ScrollBox>
97
+ <Box visible={false} height={height}>
98
+ <ScrollBox
99
+ ref={(node) => {
100
+ hiddenScrollRef = node;
101
+ }}
102
+ height={height}
103
+ scrollY
104
+ focusable={false}
105
+ >
106
+ <Box flexDirection="column">{renderRows()}</Box>
107
+ </ScrollBox>
108
+ </Box>
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ interface TestRow {
114
+ id: string;
115
+ }
116
+
117
+ const columns: DataTableColumn[] = [{ id: "label", label: "Label", width: 10, align: "left" }];
118
+ const rows: TestRow[] = [{ id: "row-1" }];
119
+
120
+ function StackDetailPane({ focused, width, height }: PaneProps) {
121
+ const detailContent = (
122
+ <ScrollBox
123
+ ref={(node) => {
124
+ scrollRef = node;
125
+ }}
126
+ flexGrow={1}
127
+ scrollY
128
+ focusable={false}
129
+ >
130
+ <Box flexDirection="column">
131
+ {Array.from({ length: 20 }, (_, index) => (
132
+ <Box key={index} height={1}>
133
+ <Text>{`Tweet detail row ${index}`}</Text>
134
+ </Box>
135
+ ))}
136
+ </Box>
137
+ </ScrollBox>
138
+ );
139
+
140
+ return (
141
+ <DataTableStackView<TestRow>
142
+ focused={focused}
143
+ detailOpen
144
+ onBack={() => {}}
145
+ detailContent={detailContent}
146
+ detailTitle="Tweet"
147
+ selectedIndex={0}
148
+ rootWidth={width}
149
+ rootHeight={height}
150
+ columns={columns}
151
+ items={rows}
152
+ sortColumnId={null}
153
+ sortDirection="asc"
154
+ onHeaderClick={() => {}}
155
+ getItemKey={(row) => row.id}
156
+ isSelected={(_row, index) => index === 0}
157
+ onSelect={() => {}}
158
+ renderCell={(row) => ({ text: row.id })}
159
+ emptyStateTitle="No rows"
160
+ />
161
+ );
162
+ }
163
+
164
+ function Harness({
165
+ component = LongScrollPane,
166
+ focused = true,
167
+ }: {
168
+ component?: (props: PaneProps) => ReactNode;
169
+ focused?: boolean;
170
+ }) {
171
+ const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-pane-keyboard-scroll-test"));
172
+ const [state, dispatch] = useReducer(appReducer, initialState);
173
+
174
+ return (
175
+ <AppContext value={{ state, dispatch }}>
176
+ <PaneContent
177
+ component={component}
178
+ paneId="test-pane:main"
179
+ paneType="test-pane"
180
+ focused={focused}
181
+ width={30}
182
+ height={5}
183
+ />
184
+ </AppContext>
185
+ );
186
+ }
187
+
188
+ describe("pane keyboard scrolling", () => {
189
+ test("scrolls the focused pane's vertical scrollbox with arrow keys", async () => {
190
+ await renderHarness(<Harness />);
191
+
192
+ expect(scrollRef?.scrollTop).toBe(0);
193
+ await emitDownArrow();
194
+
195
+ expect(scrollRef?.scrollTop).toBe(3);
196
+ });
197
+
198
+ test("does not steal arrows from existing pane shortcut handlers", async () => {
199
+ await renderHarness(<Harness component={ConsumingScrollPane} />);
200
+
201
+ expect(scrollRef?.scrollTop).toBe(0);
202
+ await emitDownArrow();
203
+
204
+ expect(scrollRef?.scrollTop).toBe(0);
205
+ });
206
+
207
+ test("ignores mounted scrollboxes inside hidden tabs", async () => {
208
+ await renderHarness(<Harness component={HiddenMountedTabPane} />);
209
+
210
+ expect(scrollRef?.scrollTop).toBe(0);
211
+ expect(hiddenScrollRef?.scrollTop).toBe(0);
212
+ await emitDownArrow();
213
+
214
+ expect(scrollRef?.scrollTop).toBe(3);
215
+ expect(hiddenScrollRef?.scrollTop).toBe(0);
216
+ });
217
+
218
+ test("scrolls stack detail content without requiring a pane-local detail handler", async () => {
219
+ await renderHarness(<Harness component={StackDetailPane} />);
220
+
221
+ expect(scrollRef?.scrollTop).toBe(0);
222
+ await emitDownArrow();
223
+
224
+ expect(scrollRef?.scrollTop).toBe(3);
225
+ });
226
+ });