gloomberb 0.8.2 → 0.9.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 (111) hide show
  1. package/README.md +9 -7
  2. package/package.json +18 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/patches/electrobun@1.18.1.patch +85 -0
  5. package/src/app/global-shortcuts.test.tsx +66 -4
  6. package/src/app/global-shortcuts.ts +12 -6
  7. package/src/assets/gloomberb-logo-windows.ico +0 -0
  8. package/src/assets/gloomberb-logo-windows.png +0 -0
  9. package/src/assets/gloomberb-logo.ico +0 -0
  10. package/src/components/command-bar/action-runtime.ts +1 -0
  11. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  12. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  13. package/src/components/command-bar/list/view.tsx +3 -1
  14. package/src/components/command-bar/pane-templates/items.ts +4 -1
  15. package/src/components/command-bar/panel/body.tsx +1 -0
  16. package/src/components/command-bar/routes/root/results.ts +0 -30
  17. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  18. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  19. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  20. package/src/components/command-bar/routing/launch-request.ts +14 -7
  21. package/src/components/command-bar/routing/list-state.ts +1 -3
  22. package/src/components/command-bar/surface/index.test.tsx +76 -4
  23. package/src/components/command-bar/surface/index.tsx +0 -2
  24. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  25. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  26. package/src/components/command-bar/view-model.ts +3 -3
  27. package/src/components/layout/detached-pane-shell.tsx +8 -4
  28. package/src/components/layout/header.tsx +13 -6
  29. package/src/components/layout/shell/index.test.tsx +120 -2
  30. package/src/components/layout/shell/index.tsx +2 -0
  31. package/src/components/layout/shell/menu.ts +1 -7
  32. package/src/components/layout/shell/pane/actions.ts +8 -0
  33. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  34. package/src/components/layout/shell/shortcuts.ts +14 -6
  35. package/src/components/layout/shell/window-mode/index.ts +8 -3
  36. package/src/components/layout/titlebar-overlay.ts +16 -0
  37. package/src/components/layout/window-controls.tsx +90 -0
  38. package/src/components/layout/window-edit/presentation.ts +4 -4
  39. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  40. package/src/components/ticker/list-table-view.tsx +6 -0
  41. package/src/components/ui/remote-image.tsx +1 -14
  42. package/src/components/ui/tabs.tsx +95 -80
  43. package/src/components/ui/ui.test.tsx +54 -0
  44. package/src/core/state/app/types.ts +12 -4
  45. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  46. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  47. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  48. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  49. package/src/plugins/builtin/help/components.tsx +1 -1
  50. package/src/plugins/builtin/help/index.test.tsx +105 -30
  51. package/src/plugins/builtin/help/index.tsx +324 -168
  52. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  53. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  54. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  55. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  57. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  58. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  59. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  60. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  61. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  62. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  63. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  64. package/src/plugins/builtin/substack/api/store.ts +186 -0
  65. package/src/plugins/builtin/substack/api/types.ts +40 -0
  66. package/src/plugins/builtin/substack/api.test.ts +298 -0
  67. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  68. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  69. package/src/plugins/builtin/substack/content.ts +478 -0
  70. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  71. package/src/plugins/builtin/substack/index.tsx +49 -0
  72. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  73. package/src/plugins/builtin/substack/model.test.ts +155 -0
  74. package/src/plugins/builtin/substack/normalize.ts +343 -0
  75. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  76. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  77. package/src/plugins/builtin/substack/pane.tsx +500 -0
  78. package/src/plugins/builtin/substack/table.ts +111 -0
  79. package/src/plugins/builtin/substack/types.ts +71 -0
  80. package/src/plugins/builtin/substack/utils.ts +131 -0
  81. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  82. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  83. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  84. package/src/plugins/catalog-ui.ts +2 -0
  85. package/src/plugins/pane-manager/layout-state.ts +11 -0
  86. package/src/plugins/pane-manager.ts +1 -1
  87. package/src/react/use-throttled-commit-value.ts +116 -0
  88. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  89. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  90. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
  91. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  92. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  93. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  94. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  95. package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
  96. package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
  97. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
  98. package/src/renderers/electrobun/bun/index.ts +28 -6
  99. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  100. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  101. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  102. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  103. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  104. package/src/renderers/electrobun/view/styles.css +20 -0
  105. package/src/renderers/electrobun/view/ui-host.tsx +14 -0
  106. package/src/state/app/context/index.test.tsx +16 -0
  107. package/src/tickers/search/index.ts +1 -0
  108. package/src/ui/host.tsx +2 -0
  109. package/src/updater.test.ts +47 -2
  110. package/src/updater.ts +67 -10
  111. package/src/version.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useShortcut } from "../../../../react/input";
3
+ import type { WindowEditMode } from "../../../../plugins/registry";
3
4
  import {
4
5
  createDoubleEscapeCloseState,
5
6
  recordDoubleEscapeClose,
@@ -12,6 +13,7 @@ import {
12
13
 
13
14
  interface ShellPaneManagementShortcutOptions {
14
15
  cancelActiveDrag(): void;
16
+ closeAllFloatingPanes(): boolean;
15
17
  closeFocusedPane(): boolean;
16
18
  copyFocusedPaneScreenshot(): boolean;
17
19
  focusedPaneId: string | null;
@@ -22,12 +24,13 @@ interface ShellPaneManagementShortcutOptions {
22
24
  openLayoutMenu(): void;
23
25
  overlayOpen: boolean;
24
26
  popOutFocusedPane(): boolean;
25
- startWindowMode(): void;
27
+ startWindowMode(paneId?: string, mode?: WindowEditMode): void;
26
28
  toggleFocusedPaneFloating(): boolean;
27
29
  }
28
30
 
29
31
  export function useShellPaneManagementShortcuts({
30
32
  cancelActiveDrag,
33
+ closeAllFloatingPanes,
31
34
  closeFocusedPane,
32
35
  copyFocusedPaneScreenshot,
33
36
  focusedPaneId,
@@ -82,6 +85,9 @@ export function useShellPaneManagementShortcuts({
82
85
  case "close":
83
86
  handled = closeFocusedPane();
84
87
  break;
88
+ case "close-all-floating":
89
+ handled = closeAllFloatingPanes();
90
+ break;
85
91
  case "settings":
86
92
  handled = openFocusedPaneSettings();
87
93
  break;
@@ -102,7 +108,11 @@ export function useShellPaneManagementShortcuts({
102
108
  handled = gridlockVisiblePanes();
103
109
  break;
104
110
  case "window-mode":
105
- startWindowMode();
111
+ startWindowMode(undefined, "move");
112
+ handled = true;
113
+ break;
114
+ case "window-resize-mode":
115
+ startWindowMode(undefined, "resize");
106
116
  handled = true;
107
117
  break;
108
118
  }
@@ -6,9 +6,11 @@ export const PANE_MANAGEMENT_ACCELERATORS = {
6
6
  popOut: "CmdOrCtrl+Shift+O",
7
7
  copyScreenshot: "CmdOrCtrl+Shift+C",
8
8
  close: "CmdOrCtrl+W",
9
+ closeAllFloating: "CmdOrCtrl+Alt+W",
9
10
  layoutActions: "CmdOrCtrl+Shift+L",
10
11
  gridlockAll: "CmdOrCtrl+Shift+G",
11
12
  windowMode: "CmdOrCtrl+Shift+M",
13
+ windowResizeMode: "CmdOrCtrl+Shift+R",
12
14
  } as const;
13
15
 
14
16
  export type PaneManagementShortcut =
@@ -17,24 +19,30 @@ export type PaneManagementShortcut =
17
19
  | "pop-out"
18
20
  | "copy-screenshot"
19
21
  | "close"
22
+ | "close-all-floating"
20
23
  | "layout-actions"
21
24
  | "gridlock-all"
22
- | "window-mode";
25
+ | "window-mode"
26
+ | "window-resize-mode";
23
27
 
24
28
  export function resolvePaneManagementShortcut(
25
29
  event: Pick<KeyEventLike, "name" | "key" | "ctrl" | "meta" | "super" | "shift" | "alt">,
26
30
  ): PaneManagementShortcut | null {
27
31
  if (!event.ctrl && !event.meta && !event.super) return null;
28
- const name = (event.name ?? event.key ?? "").toLowerCase();
29
- if (name === "w") return "close";
30
- if (!event.shift && name === ",") return "settings";
31
- if (!event.shift || event.alt) return null;
32
+ const rawName = event.name ?? event.key ?? "";
33
+ const name = rawName.toLowerCase();
34
+ const shifted = event.shift || rawName !== name;
35
+ if (!shifted && event.alt && name === "w") return "close-all-floating";
36
+ if (!shifted && name === "w") return "close";
37
+ if (!shifted && name === ",") return "settings";
38
+ if (!shifted || event.alt) return null;
32
39
  if (name === "c") return "copy-screenshot";
33
40
  if (name === "d") return "toggle-floating";
34
41
  if (name === "o") return "pop-out";
35
42
  if (name === "l") return "layout-actions";
36
43
  if (name === "g") return "gridlock-all";
37
44
  if (name === "m") return "window-mode";
45
+ if (name === "r") return "window-resize-mode";
38
46
  return null;
39
47
  }
40
48
 
@@ -42,6 +50,6 @@ export function inputCaptureAllowsPaneManagementShortcut(
42
50
  shortcut: PaneManagementShortcut,
43
51
  event: Pick<KeyEventLike, "meta" | "super" | "targetEditable">,
44
52
  ): boolean {
45
- if (shortcut !== "close") return false;
53
+ if (shortcut !== "close" && shortcut !== "close-all-floating") return false;
46
54
  return event.meta || event.super || event.targetEditable !== true;
47
55
  }
@@ -128,9 +128,13 @@ export function useShellWindowMode({
128
128
  if (!windowMode) return;
129
129
  const committedLayout = resolveWindowEditCommitLayout(windowMode, bounds, dockGeometryOptions);
130
130
  const hasPendingCommit = windowEditHasPendingCommit(windowMode, bounds, dockGeometryOptions);
131
- if (hasPendingCommit) {
132
- persistLayout(committedLayout);
131
+ if (!hasPendingCommit) {
132
+ focusPane(windowMode.paneId);
133
+ setWindowMode(null);
134
+ return;
133
135
  }
136
+
137
+ persistLayout(committedLayout);
134
138
  focusPane(windowMode.paneId);
135
139
  setWindowMode({
136
140
  paneId: windowMode.paneId,
@@ -138,7 +142,7 @@ export function useShellWindowMode({
138
142
  mode: "move",
139
143
  focus: normalizeWindowEditFocus({ kind: "move" }, committedLayout, windowMode.paneId, "move", bounds, dockGeometryOptions),
140
144
  dirty: false,
141
- notice: hasPendingCommit ? "Committed" : "No changes",
145
+ notice: "Committed",
142
146
  });
143
147
  }, [bounds, dockGeometryOptions, focusPane, persistLayout, windowMode]);
144
148
 
@@ -166,6 +170,7 @@ export function useShellWindowMode({
166
170
 
167
171
  useShortcut((event) => {
168
172
  if (!windowMode) return;
173
+ if (event.ctrl || event.meta || event.super || event.alt) return;
169
174
  const name = (event.name ?? event.key ?? "").toLowerCase();
170
175
  let handled = true;
171
176
 
@@ -1,2 +1,18 @@
1
1
  export const TITLEBAR_TRAFFIC_LIGHT_WIDTH = 8;
2
2
  export const TITLEBAR_OVERLAY_HEIGHT_PX = 28;
3
+
4
+ function currentPlatform(): string {
5
+ const globalWithNavigator = globalThis as typeof globalThis & {
6
+ navigator?: {
7
+ platform?: string;
8
+ userAgentData?: { platform?: string };
9
+ };
10
+ };
11
+ return globalWithNavigator.navigator?.userAgentData?.platform
12
+ ?? globalWithNavigator.navigator?.platform
13
+ ?? "";
14
+ }
15
+
16
+ export function getTitlebarLeadingInset(platform = currentPlatform()): number {
17
+ return /mac/i.test(platform) ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 0;
18
+ }
@@ -0,0 +1,90 @@
1
+ import { Box, useRendererHost } from "../../ui";
2
+ import { useCallback } from "react";
3
+
4
+ const WINDOWS_CONTROL_WIDTH_PX = 30;
5
+ const WINDOWS_CONTROL_TRAILING_PADDING_PX = 10;
6
+
7
+ type WindowControlAction = "minimize" | "toggle-maximize" | "close";
8
+
9
+ const WINDOWS_CONTROLS: Array<{
10
+ action: WindowControlAction;
11
+ label: string;
12
+ }> = [
13
+ { action: "minimize", label: "Minimize" },
14
+ { action: "toggle-maximize", label: "Maximize" },
15
+ { action: "close", label: "Close" },
16
+ ];
17
+
18
+ function stopMouse(event: { stopPropagation?: () => void; preventDefault?: () => void }) {
19
+ event.stopPropagation?.();
20
+ event.preventDefault?.();
21
+ }
22
+
23
+ function WindowControlIcon({ action }: { action: WindowControlAction }) {
24
+ if (action === "minimize") {
25
+ return (
26
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
27
+ <path d="M2.5 6.5H9.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="square" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ if (action === "toggle-maximize") {
33
+ return (
34
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
35
+ <rect x="3" y="3" width="6" height="6" stroke="currentColor" strokeWidth="1.25" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
42
+ <path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ export function WindowControls() {
48
+ const rendererHost = useRendererHost();
49
+
50
+ const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
51
+ stopMouse(event);
52
+ void rendererHost.controlWindow?.(action);
53
+ }, [rendererHost]);
54
+
55
+ return (
56
+ <Box
57
+ flexDirection="row"
58
+ alignItems="stretch"
59
+ flexShrink={0}
60
+ className="electrobun-webkit-app-region-no-drag"
61
+ data-gloom-role="window-controls"
62
+ aria-hidden={false}
63
+ style={{
64
+ paddingRight: WINDOWS_CONTROL_TRAILING_PADDING_PX,
65
+ }}
66
+ >
67
+ {WINDOWS_CONTROLS.map((control) => (
68
+ <Box
69
+ key={control.action}
70
+ alignItems="center"
71
+ justifyContent="center"
72
+ data-gloom-role="window-control"
73
+ data-window-control-action={control.action}
74
+ data-gloom-interactive="true"
75
+ role="button"
76
+ aria-label={control.label}
77
+ title={control.label}
78
+ className="electrobun-webkit-app-region-no-drag"
79
+ onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
80
+ style={{
81
+ width: WINDOWS_CONTROL_WIDTH_PX,
82
+ height: "100%",
83
+ }}
84
+ >
85
+ <WindowControlIcon action={control.action} />
86
+ </Box>
87
+ ))}
88
+ </Box>
89
+ );
90
+ }
@@ -73,14 +73,14 @@ export function windowEditStatusLine(
73
73
  export function windowEditHelpText(state: WindowEditState): string {
74
74
  if (state.mode === "move") {
75
75
  if (state.focus.kind === "dock-move") {
76
- return "arrows/hjkl choose side m retarget Tab/w window d float r resize Enter commit Esc exit/cancel";
76
+ return "arrows/hjkl choose side m retarget Tab/w window d float r resize Enter commit/exit Esc exit/cancel";
77
77
  }
78
- return "Tab/w window d dock/float r resize arrows/hjkl target/move Shift fast Enter commit Esc exit/cancel";
78
+ return "Tab/w window d dock/float r resize arrows/hjkl target/move Shift fast Enter commit/exit Esc exit/cancel";
79
79
  }
80
80
  if (state.focus.kind === "floating-resize") {
81
- return "Tab corner w window m move arrows/hjkl resize Shift fast Enter commit Esc exit/cancel";
81
+ return "Tab corner w window m move arrows/hjkl resize Shift fast Enter commit/exit Esc exit/cancel";
82
82
  }
83
- return "arrows/hjkl move divider Tab divider w window m move Shift fast Enter commit Esc exit/cancel";
83
+ return "arrows/hjkl move divider Tab divider w window m move Shift fast Enter commit/exit Esc exit/cancel";
84
84
  }
85
85
 
86
86
  export function resolveWindowEditDockMovePreview(
@@ -101,7 +101,8 @@ export function ShortcutsStep({ pluginRegistry }: { pluginRegistry: PluginRegist
101
101
  const shortcutDisplayMode = getShortcutDisplayMode(uiHost.kind);
102
102
  const platformShortcut = (keys: string | readonly string[]) => formatPrimaryShortcut(keys, shortcutPlatform, shortcutDisplayMode);
103
103
  const keyboardShortcuts = [
104
- { key: "Ctrl+P / `", desc: "Open the command bar" },
104
+ { key: "Ctrl+P", desc: "Open command mode" },
105
+ { key: "`", desc: "Open ticker search" },
105
106
  { key: "Tab", desc: "Switch between panels" },
106
107
  { key: platformShortcut("W"), desc: "Close the focused pane" },
107
108
  { key: platformShortcut(["Shift", "D"]), desc: "Dock or float the focused pane" },
@@ -155,7 +156,7 @@ export function ShortcutsStep({ pluginRegistry }: { pluginRegistry: PluginRegist
155
156
  </Box>
156
157
  <Box height={1} />
157
158
  <Box height={1}>
158
- <Text fg={colors.textDim}>{"Type these in the command bar ("}<Span fg={colors.text}><Strong>{"Ctrl+P"}</Strong></Span>{" or "}<Span fg={colors.text}><Strong>{"`"}</Strong></Span>{"):"}</Text>
159
+ <Text fg={colors.textDim}>{"Type these in the command bar ("}<Span fg={colors.text}><Strong>{"Ctrl+P"}</Strong></Span>{"):"}</Text>
159
160
  </Box>
160
161
  <Box height={1} />
161
162
 
@@ -81,12 +81,18 @@ const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
81
81
  "bid",
82
82
  "ask",
83
83
  "spread",
84
+ "spread_pct",
84
85
  "ext_hours",
85
86
  "market_cap",
87
+ "volume",
88
+ "dollar_volume",
89
+ "range_52w",
86
90
  "mkt_value",
91
+ "weight",
87
92
  "day_pnl",
88
93
  "pnl",
89
94
  "pnl_pct",
95
+ "mark_delta",
90
96
  ]);
91
97
 
92
98
  const EMPTY_FLASH_SYMBOLS = new Map<string, QuoteFlashDirection>();
@@ -1,6 +1,5 @@
1
1
  import { Box, ImageSurface, Text } from "../../ui";
2
2
  import { colors } from "../../theme/colors";
3
- import { ExternalLinkText } from "./external-link";
4
3
  import { normalizedHttpUrl } from "../../utils/url";
5
4
 
6
5
  export interface RemoteImageProps {
@@ -11,13 +10,6 @@ export interface RemoteImageProps {
11
10
  label?: string;
12
11
  }
13
12
 
14
- function truncate(value: string, width: number): string {
15
- if (width <= 0) return "";
16
- if (value.length <= width) return value;
17
- if (width <= 3) return value.slice(0, width);
18
- return `${value.slice(0, width - 3)}...`;
19
- }
20
-
21
13
  export function RemoteImage({
22
14
  src,
23
15
  alt = "Image",
@@ -37,13 +29,8 @@ export function RemoteImage({
37
29
  height={Math.max(4, height)}
38
30
  objectFit="contain"
39
31
  >
40
- <Box flexDirection="column" gap={1}>
32
+ <Box flexDirection="column">
41
33
  <Text fg={colors.textDim}>{label}</Text>
42
- <ExternalLinkText
43
- url={imageUrl}
44
- label={truncate(imageUrl, resolvedWidth)}
45
- color={colors.textBright}
46
- />
47
34
  </Box>
48
35
  </ImageSurface>
49
36
  );
@@ -30,6 +30,7 @@ export interface TabsProps {
30
30
  onAdd?: () => void;
31
31
  focused?: boolean;
32
32
  keyboardNavigation?: boolean;
33
+ scrollable?: boolean;
33
34
  }
34
35
 
35
36
  const WHEEL_DELTA_PER_CELL = 8;
@@ -45,6 +46,7 @@ export function Tabs({
45
46
  onAdd,
46
47
  focused = false,
47
48
  keyboardNavigation = true,
49
+ scrollable = true,
48
50
  }: TabsProps) {
49
51
  const ui = useUiHost();
50
52
  const NativeTabs = ui.Tabs;
@@ -131,6 +133,7 @@ export function Tabs({
131
133
  closeMode={closeMode}
132
134
  addLabel={addLabel}
133
135
  onAdd={onAdd}
136
+ scrollable={scrollable}
134
137
  palette={palette}
135
138
  />
136
139
  );
@@ -154,6 +157,7 @@ function OpenTuiTabs({
154
157
  closeMode = "always",
155
158
  addLabel = "+",
156
159
  onAdd,
160
+ scrollable = true,
157
161
  palette,
158
162
  }: TabsProps & {
159
163
  palette: {
@@ -227,6 +231,96 @@ function OpenTuiTabs({
227
231
  scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
228
232
  };
229
233
 
234
+ const tabRow = (
235
+ <Box flexDirection="row" width={totalWidth} height={1}>
236
+ {tabs.map((tab, index) => {
237
+ const active = tab.value === activeValue;
238
+ const hovered = hoveredValue === tab.value && !tab.disabled;
239
+ const tabWidth = tabWidths[index] ?? tab.label.length + 2;
240
+ const showClose = !!tab.onClose && (closeMode === "always" || active);
241
+ const attributes = (active ? TextAttributes.BOLD : 0)
242
+ | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
243
+ const startHover = tab.disabled
244
+ ? undefined
245
+ : () => {
246
+ setHoveredValue((current) => (current === tab.value ? current : tab.value));
247
+ };
248
+ const endHover = tab.disabled
249
+ ? undefined
250
+ : () => {
251
+ setHoveredValue((current) => (current === tab.value ? null : current));
252
+ };
253
+ const selectTab = tab.disabled
254
+ ? undefined
255
+ : (event: TabPointerEvent) => {
256
+ if (event.button === 2 && tab.onContextMenu) {
257
+ event.preventDefault?.();
258
+ event.stopPropagation?.();
259
+ tab.onContextMenu(tab.value, event);
260
+ return;
261
+ }
262
+ event.preventDefault();
263
+ event.stopPropagation?.();
264
+ onSelect(tab.value);
265
+ };
266
+
267
+ return (
268
+ <Box
269
+ key={tab.value}
270
+ width={tabWidth}
271
+ height={1}
272
+ flexDirection="row"
273
+ backgroundColor={active && variant === "pill" ? palette.activeBg : hovered ? palette.hoverBg : undefined}
274
+ onMouseOver={startHover}
275
+ onMouseMove={startHover}
276
+ onMouseOut={endHover}
277
+ onMouseDown={selectTab}
278
+ onDoubleClick={tab.disabled || !tab.onDoubleClick ? undefined : () => tab.onDoubleClick?.(tab.value)}
279
+ >
280
+ <Text
281
+ fg={tab.disabled ? palette.disabledFg : active && variant === "pill" ? palette.activePillFg : active ? palette.activeFg : hovered ? palette.hoverFg : palette.inactiveFg}
282
+ attributes={attributes}
283
+ onMouseDown={selectTab}
284
+ >
285
+ {` ${tab.label} `}
286
+ </Text>
287
+ {showClose && (
288
+ <Text
289
+ fg={active && variant === "pill" ? palette.activePillFg : palette.closeFg}
290
+ onMouseDown={(event: any) => {
291
+ event.preventDefault?.();
292
+ event.stopPropagation?.();
293
+ tab.onClose?.(tab.value);
294
+ }}
295
+ >
296
+ {"x "}
297
+ </Text>
298
+ )}
299
+ </Box>
300
+ );
301
+ })}
302
+ {onAdd && (
303
+ <Box
304
+ width={addWidth}
305
+ height={1}
306
+ backgroundColor={hoveredValue === "__add__" ? palette.hoverBg : undefined}
307
+ onMouseMove={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
308
+ onMouseOut={() => setHoveredValue((current) => (current === "__add__" ? null : current))}
309
+ onMouseDown={(event: TabPointerEvent) => {
310
+ event.preventDefault();
311
+ onAdd();
312
+ }}
313
+ >
314
+ <Text fg={hoveredValue === "__add__" ? palette.hoverFg : palette.addFg}>
315
+ {` ${addLabel} `}
316
+ </Text>
317
+ </Box>
318
+ )}
319
+ </Box>
320
+ );
321
+
322
+ if (!scrollable) return tabRow;
323
+
230
324
  return (
231
325
  <ScrollBox
232
326
  ref={scrollRef}
@@ -237,86 +331,7 @@ function OpenTuiTabs({
237
331
  horizontalScrollbarOptions={{ visible: false }}
238
332
  onMouseScroll={handleMouseScroll}
239
333
  >
240
- <Box flexDirection="row" width={totalWidth} height={1}>
241
- {tabs.map((tab, index) => {
242
- const active = tab.value === activeValue;
243
- const hovered = hoveredValue === tab.value && !tab.disabled;
244
- const tabWidth = tabWidths[index] ?? tab.label.length + 2;
245
- const showClose = !!tab.onClose && (closeMode === "always" || active);
246
- const attributes = (active ? TextAttributes.BOLD : 0)
247
- | (variant === "underline" && !compact && (active || hovered) ? TextAttributes.UNDERLINE : 0);
248
- const startHover = tab.disabled
249
- ? undefined
250
- : () => {
251
- setHoveredValue((current) => (current === tab.value ? current : tab.value));
252
- };
253
- const endHover = tab.disabled
254
- ? undefined
255
- : () => {
256
- setHoveredValue((current) => (current === tab.value ? null : current));
257
- };
258
-
259
- return (
260
- <Box
261
- key={tab.value}
262
- width={tabWidth}
263
- height={1}
264
- flexDirection="row"
265
- backgroundColor={active && variant === "pill" ? palette.activeBg : hovered ? palette.hoverBg : undefined}
266
- onMouseOver={startHover}
267
- onMouseMove={startHover}
268
- onMouseOut={endHover}
269
- onMouseDown={tab.disabled ? undefined : (event: TabPointerEvent) => {
270
- if (event.button === 2 && tab.onContextMenu) {
271
- event.preventDefault?.();
272
- event.stopPropagation?.();
273
- tab.onContextMenu(tab.value, event);
274
- return;
275
- }
276
- event.preventDefault();
277
- onSelect(tab.value);
278
- }}
279
- onDoubleClick={tab.disabled || !tab.onDoubleClick ? undefined : () => tab.onDoubleClick?.(tab.value)}
280
- >
281
- <Text
282
- fg={tab.disabled ? palette.disabledFg : active && variant === "pill" ? palette.activePillFg : active ? palette.activeFg : hovered ? palette.hoverFg : palette.inactiveFg}
283
- attributes={attributes}
284
- >
285
- {` ${tab.label} `}
286
- </Text>
287
- {showClose && (
288
- <Text
289
- fg={active && variant === "pill" ? palette.activePillFg : palette.closeFg}
290
- onMouseDown={(event: any) => {
291
- event.preventDefault?.();
292
- event.stopPropagation?.();
293
- tab.onClose?.(tab.value);
294
- }}
295
- >
296
- {"x "}
297
- </Text>
298
- )}
299
- </Box>
300
- );
301
- })}
302
- {onAdd && (
303
- <Box
304
- width={addWidth}
305
- height={1}
306
- backgroundColor={hoveredValue === "__add__" ? palette.hoverBg : undefined}
307
- onMouseMove={() => setHoveredValue((current) => (current === "__add__" ? current : "__add__"))}
308
- onMouseOut={() => setHoveredValue((current) => (current === "__add__" ? null : current))}
309
- onMouseDown={(event: TabPointerEvent) => {
310
- event.preventDefault();
311
- onAdd();
312
- }}
313
- >
314
- <Text fg={hoveredValue === "__add__" ? palette.hoverFg : palette.addFg}>
315
- {` ${addLabel} `}
316
- </Text>
317
- </Box>
318
- )}
319
- </Box>
334
+ {tabRow}
320
335
  </ScrollBox>
321
336
  );
322
337
  }
@@ -360,6 +360,60 @@ describe("shared UI kit", () => {
360
360
  expect(lastSelected).toBe("chart");
361
361
  });
362
362
 
363
+ test("selects tabs by clicking their text labels", async () => {
364
+ let lastSelected = "overview";
365
+ const selectedValues: string[] = [];
366
+
367
+ function PointerTabsHarness() {
368
+ const [activeValue, setActiveValue] = useState("overview");
369
+ return (
370
+ <Tabs
371
+ tabs={[
372
+ { label: "Overview", value: "overview" },
373
+ { label: "News", value: "news" },
374
+ { label: "Chart", value: "chart" },
375
+ ]}
376
+ activeValue={activeValue}
377
+ onSelect={(value) => {
378
+ lastSelected = value;
379
+ selectedValues.push(value);
380
+ setActiveValue(value);
381
+ }}
382
+ scrollable={false}
383
+ />
384
+ );
385
+ }
386
+
387
+ testSetup = await testRender(<PointerTabsHarness />, { width: 40, height: 4 });
388
+
389
+ await act(async () => {
390
+ await testSetup!.renderOnce();
391
+ });
392
+
393
+ let frame = testSetup.captureCharFrame();
394
+ const newsCol = frame.split("\n")[0]!.indexOf("News");
395
+ expect(newsCol).toBeGreaterThanOrEqual(0);
396
+
397
+ await act(async () => {
398
+ await testSetup!.mockMouse.click(newsCol + 1, 0);
399
+ await testSetup!.renderOnce();
400
+ });
401
+
402
+ expect(lastSelected).toBe("news");
403
+
404
+ frame = testSetup.captureCharFrame();
405
+ const chartCol = frame.split("\n")[0]!.indexOf("Chart");
406
+ expect(chartCol).toBeGreaterThanOrEqual(0);
407
+
408
+ await act(async () => {
409
+ await testSetup!.mockMouse.click(chartCol + 1, 0);
410
+ await testSetup!.renderOnce();
411
+ });
412
+
413
+ expect(selectedValues).toEqual(["news", "chart"]);
414
+ expect(lastSelected).toBe("chart");
415
+ });
416
+
363
417
  test("renders tab actions for editable tab sets", async () => {
364
418
  testSetup = await testRender(
365
419
  <Tabs
@@ -26,10 +26,18 @@ export interface CollectionSortPreference {
26
26
  direction: SortDirection;
27
27
  }
28
28
 
29
- interface CommandBarLaunchRequest {
29
+ interface CommandBarLaunchRequestBase {
30
+ sequence: number;
31
+ }
32
+
33
+ interface CommandBarPluginLaunchRequest extends CommandBarLaunchRequestBase {
30
34
  kind: "plugin-command";
31
35
  commandId: string;
32
- sequence: number;
36
+ }
37
+
38
+ interface CommandBarTickerSearchLaunchRequest extends CommandBarLaunchRequestBase {
39
+ kind: "ticker-search";
40
+ query?: string;
33
41
  }
34
42
 
35
43
  export interface AppState {
@@ -44,7 +52,7 @@ export interface AppState {
44
52
  recentTickers: string[];
45
53
  commandBarOpen: boolean;
46
54
  commandBarQuery: string;
47
- commandBarLaunchRequest: CommandBarLaunchRequest | null;
55
+ commandBarLaunchRequest: CommandBarPluginLaunchRequest | CommandBarTickerSearchLaunchRequest | null;
48
56
  themePreview: string | null;
49
57
  refreshing: Set<string>;
50
58
  initialized: boolean;
@@ -74,7 +82,7 @@ export type AppAction =
74
82
  type: "SET_COMMAND_BAR";
75
83
  open: boolean;
76
84
  query?: string;
77
- launch?: { kind: "plugin-command"; commandId: string } | null;
85
+ launch?: { kind: "plugin-command"; commandId: string } | { kind: "ticker-search"; query?: string } | null;
78
86
  }
79
87
  | { type: "SET_COMMAND_BAR_QUERY"; query: string }
80
88
  | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }