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,12 +1,15 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
3
  import type { ChatChannel, ChatUserSummary } from "../../../../api-client";
4
+ import { useThrottledCommitValue } from "../../../../react/use-throttled-commit-value";
4
5
  import {
5
6
  DEFAULT_CHAT_CHANNEL_ID,
6
7
  normalizeChannelId,
7
8
  } from "../channels";
8
9
  import type { ChatContentController } from "./types";
9
10
 
11
+ const CHANNEL_NAVIGATION_COMMIT_DELAY_MS = 150;
12
+
10
13
  function isConversationChannelId(channelId: string): boolean {
11
14
  return channelId.startsWith("dm:") || channelId.startsWith("grp:") || channelId.startsWith("group:");
12
15
  }
@@ -38,15 +41,16 @@ export function useChatChannelNavigation({
38
41
  resetTranscriptSelection: () => void;
39
42
  showChannelSidebar: boolean;
40
43
  }): {
41
- changeChannel: (nextChannelId: string) => void;
42
44
  cycleChannel: (direction: 1 | -1) => boolean;
43
45
  directExpanded: boolean;
44
46
  focusChannelSidebar: () => boolean;
45
47
  focusChatContent: () => boolean;
46
48
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
47
49
  openDirectMessage: (target: ChatUserSummary) => Promise<void>;
50
+ selectSidebarChannel: (channelId: string) => void;
48
51
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
49
52
  setSidebarFocused: (nextFocused: boolean) => void;
53
+ sidebarCursorChannelId: string;
50
54
  sidebarFocused: boolean;
51
55
  sidebarFocusedRef: MutableRefObject<boolean>;
52
56
  } {
@@ -70,6 +74,13 @@ export function useChatChannelNavigation({
70
74
  resetTranscriptSelection();
71
75
  onChannelChange?.(normalized);
72
76
  }, [channelIdRef, onChannelChange, resetTranscriptSelection]);
77
+ const {
78
+ value: sidebarCursorChannelId,
79
+ valueRef: sidebarCursorChannelIdRef,
80
+ setValue: setSidebarCursorChannelId,
81
+ flushValue: flushSidebarCursorChannelId,
82
+ replaceValue: replaceSidebarCursorChannelId,
83
+ } = useThrottledCommitValue(channelId, changeChannel, CHANNEL_NAVIGATION_COMMIT_DELAY_MS);
73
84
 
74
85
  const openDirectMessage = useCallback(async (target: ChatUserSummary) => {
75
86
  if (!target.id && !target.username) return;
@@ -79,10 +90,10 @@ export function useChatChannelNavigation({
79
90
  username: target.username ?? undefined,
80
91
  });
81
92
  setDirectExpanded(true);
82
- changeChannel(channel.id);
93
+ setSidebarCursorChannelId(channel.id, { immediate: true });
83
94
  closeProfilePopover();
84
95
  } catch {}
85
- }, [changeChannel, closeProfilePopover, controller]);
96
+ }, [closeProfilePopover, controller, setSidebarCursorChannelId]);
86
97
 
87
98
  useEffect(() => {
88
99
  if (!onChannelChange || channelsLoading || channels.length === 0) return;
@@ -109,9 +120,9 @@ export function useChatChannelNavigation({
109
120
  const nextIndex = (currentIndex + direction + channels.length) % channels.length;
110
121
  const nextChannel = channels[nextIndex];
111
122
  if (!nextChannel) return false;
112
- changeChannel(nextChannel.id);
123
+ setSidebarCursorChannelId(nextChannel.id, { immediate: true });
113
124
  return true;
114
- }, [changeChannel, channelIdRef, channels, onChannelChange]);
125
+ }, [channelIdRef, channels, onChannelChange, setSidebarCursorChannelId]);
115
126
 
116
127
  const focusChannelSidebar = useCallback(() => {
117
128
  if (!showChannelSidebar || !onChannelChange) return false;
@@ -119,28 +130,49 @@ export function useChatChannelNavigation({
119
130
  blurInput();
120
131
  }
121
132
  resetTranscriptSelection();
133
+ replaceSidebarCursorChannelId(channelId);
122
134
  setSidebarFocused(true);
123
135
  return true;
124
- }, [blurInput, inputFocused, onChannelChange, resetTranscriptSelection, setSidebarFocused, showChannelSidebar]);
136
+ }, [
137
+ blurInput,
138
+ channelId,
139
+ inputFocused,
140
+ onChannelChange,
141
+ replaceSidebarCursorChannelId,
142
+ resetTranscriptSelection,
143
+ setSidebarFocused,
144
+ showChannelSidebar,
145
+ ]);
125
146
 
126
147
  const focusChatContent = useCallback(() => {
127
148
  if (!showChannelSidebar) return false;
149
+ flushSidebarCursorChannelId(sidebarCursorChannelIdRef.current);
128
150
  setSidebarFocused(false);
129
151
  return true;
130
- }, [setSidebarFocused, showChannelSidebar]);
152
+ }, [flushSidebarCursorChannelId, setSidebarFocused, showChannelSidebar, sidebarCursorChannelIdRef]);
131
153
 
132
154
  const moveSidebarChannelSelection = useCallback((direction: "up" | "down") => {
133
155
  if (!showChannelSidebar || sidebarNavigationChannels.length <= 1 || !onChannelChange) return false;
134
- const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === channelIdRef.current);
156
+ const currentIndex = sidebarNavigationChannels.findIndex((channel) => channel.id === sidebarCursorChannelIdRef.current);
135
157
  const baseIndex = currentIndex >= 0 ? currentIndex : 0;
136
158
  const nextIndex = direction === "down"
137
159
  ? Math.min(baseIndex + 1, sidebarNavigationChannels.length - 1)
138
160
  : Math.max(baseIndex - 1, 0);
139
161
  const nextChannel = sidebarNavigationChannels[nextIndex];
140
162
  if (!nextChannel || nextIndex === baseIndex) return true;
141
- changeChannel(nextChannel.id);
163
+ setSidebarCursorChannelId(nextChannel.id);
142
164
  return true;
143
- }, [changeChannel, channelIdRef, onChannelChange, showChannelSidebar, sidebarNavigationChannels]);
165
+ }, [
166
+ onChannelChange,
167
+ setSidebarCursorChannelId,
168
+ showChannelSidebar,
169
+ sidebarCursorChannelIdRef,
170
+ sidebarNavigationChannels,
171
+ ]);
172
+
173
+ const selectSidebarChannel = useCallback((nextChannelId: string) => {
174
+ setSidebarCursorChannelId(nextChannelId, { immediate: true });
175
+ }, [setSidebarCursorChannelId]);
144
176
 
145
177
  useEffect(() => {
146
178
  if (focused && showChannelSidebar) return;
@@ -148,15 +180,16 @@ export function useChatChannelNavigation({
148
180
  }, [focused, setSidebarFocused, showChannelSidebar]);
149
181
 
150
182
  return {
151
- changeChannel,
152
183
  cycleChannel,
153
184
  directExpanded,
154
185
  focusChannelSidebar,
155
186
  focusChatContent,
156
187
  moveSidebarChannelSelection,
157
188
  openDirectMessage,
189
+ selectSidebarChannel,
158
190
  setDirectExpanded,
159
191
  setSidebarFocused,
192
+ sidebarCursorChannelId,
160
193
  sidebarFocused,
161
194
  sidebarFocusedRef,
162
195
  };
@@ -212,15 +212,16 @@ export function ChatContent({
212
212
  });
213
213
 
214
214
  const {
215
- changeChannel,
216
215
  cycleChannel,
217
216
  directExpanded,
218
217
  focusChannelSidebar,
219
218
  focusChatContent,
220
219
  moveSidebarChannelSelection,
221
220
  openDirectMessage,
221
+ selectSidebarChannel,
222
222
  setDirectExpanded,
223
223
  setSidebarFocused,
224
+ sidebarCursorChannelId,
224
225
  sidebarFocused,
225
226
  sidebarFocusedRef,
226
227
  } = useChatChannelNavigation({
@@ -367,7 +368,7 @@ export function ChatContent({
367
368
  <ChannelSidebar
368
369
  channels={channels}
369
370
  channelStates={channelStates}
370
- activeChannelId={channelId}
371
+ activeChannelId={sidebarFocused ? sidebarCursorChannelId : channelId}
371
372
  onlineCount={onlineCount}
372
373
  width={channelSidebarWidth}
373
374
  height={height}
@@ -376,7 +377,7 @@ export function ChatContent({
376
377
  loading={channelsLoading}
377
378
  canManageNotifications={!!user?.emailVerified}
378
379
  directExpanded={directExpanded}
379
- onSelect={changeChannel}
380
+ onSelect={selectSidebarChannel}
380
381
  onFocusRequest={() => setSidebarFocused(true)}
381
382
  onToggleNotifications={(nextChannelId, enabled) => {
382
383
  controller.setChannelNotificationsEnabled(nextChannelId, enabled);
@@ -392,7 +393,7 @@ export function ChatContent({
392
393
  flexGrow={nativePaneChrome ? 1 : undefined}
393
394
  backgroundColor={chatContentBg}
394
395
  position="relative"
395
- onMouseDown={() => setSidebarFocused(false)}
396
+ onMouseDown={() => focusChatContent()}
396
397
  style={nativeFillStyle}
397
398
  >
398
399
  {!nativePaneChrome && (
@@ -75,6 +75,13 @@ export function useChatContentShortcuts({
75
75
  const isEnterKey = event.name === "return" || event.name === "enter";
76
76
 
77
77
  if (sidebarFocusedRef.current && showChannelSidebar) {
78
+ if (isEnterKey) {
79
+ event.preventDefault?.();
80
+ event.stopPropagation?.();
81
+ focusChatContent();
82
+ return;
83
+ }
84
+
78
85
  if (isPlainKey(event, "left")) {
79
86
  event.preventDefault?.();
80
87
  event.stopPropagation?.();
@@ -9,6 +9,7 @@ import { PluginRenderProvider } from "../../runtime";
9
9
  import { gloomberbCloudPlugin } from "../cloud";
10
10
  import { ChatContent } from "../chat";
11
11
  import { chatController } from "./controller";
12
+ import { useChatChannelNavigation } from "./content/channel-navigation";
12
13
  import {
13
14
  cleanupChatTest,
14
15
  createChatTestControls,
@@ -285,6 +286,76 @@ describe("ChatContent channel sidebar", () => {
285
286
  expect(setup().captureCharFrame()).toContain("#equities");
286
287
  });
287
288
 
289
+ test("coalesces rapid sidebar navigation to the final channel", async () => {
290
+ const controller = createController({ sessionToken: "token-123" });
291
+ const channels = [
292
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
293
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
294
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
295
+ { id: "macro", name: "macro", created_at: "2026-05-09T00:00:00.000Z" },
296
+ { id: "crypto", name: "crypto", created_at: "2026-05-09T00:00:00.000Z" },
297
+ ];
298
+ const channelChanges: string[] = [];
299
+ let navigation: ReturnType<typeof useChatChannelNavigation> | null = null;
300
+ let latestCursorChannelId = "";
301
+ let latestCommittedChannelId = "";
302
+
303
+ function NavigationHarness() {
304
+ const [channelId, setChannelId] = useState("equities");
305
+ const channelIdRef = useRef(channelId);
306
+ channelIdRef.current = channelId;
307
+ const handleChannelChange = useCallback((nextChannelId: string) => {
308
+ channelChanges.push(nextChannelId);
309
+ setChannelId(nextChannelId);
310
+ }, []);
311
+ navigation = useChatChannelNavigation({
312
+ blurInput: () => {},
313
+ channelId,
314
+ channelIdRef,
315
+ channels,
316
+ channelsLoading: false,
317
+ closeProfilePopover: () => {},
318
+ controller,
319
+ focused: true,
320
+ inputFocused: false,
321
+ onChannelChange: handleChannelChange,
322
+ resetTranscriptSelection: () => {},
323
+ showChannelSidebar: true,
324
+ });
325
+ latestCursorChannelId = navigation.sidebarCursorChannelId;
326
+ latestCommittedChannelId = channelId;
327
+
328
+ return null;
329
+ }
330
+
331
+ await act(async () => {
332
+ testSetup = await testRender(<NavigationHarness />, {
333
+ width: 90,
334
+ height: 12,
335
+ });
336
+ });
337
+
338
+ await act(async () => {
339
+ navigation?.focusChannelSidebar();
340
+ navigation?.moveSidebarChannelSelection("down");
341
+ navigation?.moveSidebarChannelSelection("down");
342
+ navigation?.moveSidebarChannelSelection("down");
343
+ await setup().renderOnce();
344
+ });
345
+
346
+ expect(latestCursorChannelId).toBe("crypto");
347
+ expect(latestCommittedChannelId).toBe("equities");
348
+ expect(channelChanges).toEqual([]);
349
+
350
+ await act(async () => {
351
+ await new Promise((resolve) => setTimeout(resolve, 180));
352
+ await setup().renderOnce();
353
+ });
354
+
355
+ expect(channelChanges).toEqual(["crypto"]);
356
+ expect(latestCommittedChannelId).toBe("crypto");
357
+ });
358
+
288
359
  test("keeps the channel sidebar visible while a channel loads", async () => {
289
360
  const controller = createController({ sessionToken: "token-123" });
290
361
  installServerChannels(controller);
@@ -29,7 +29,7 @@ export function ShortcutRow({
29
29
  return (
30
30
  <Box flexDirection="row" gap={1}>
31
31
  <Box flexDirection="row" gap={1} flexShrink={0}>
32
- {badges.map((badge) => <ShortcutBadge key={badge} label={badge} />)}
32
+ {badges.map((badge, index) => <ShortcutBadge key={`${badge}:${index}`} label={badge} />)}
33
33
  </Box>
34
34
  <Box flexGrow={1}>
35
35
  <Text fg={colors.text} wrapText>{description}</Text>
@@ -34,32 +34,69 @@ async function renderHelpPane(
34
34
  size,
35
35
  );
36
36
 
37
+ await testSetup.renderOnce();
37
38
  await testSetup.renderOnce();
38
39
  return testSetup;
39
40
  }
40
41
 
42
+ async function moveHelpTabRight(count = 1) {
43
+ await act(async () => {
44
+ for (let index = 0; index < count; index += 1) {
45
+ testSetup!.mockInput.pressArrow("right");
46
+ }
47
+ await testSetup!.renderOnce();
48
+ await testSetup!.renderOnce();
49
+ });
50
+ }
51
+
52
+ async function resetRenderedHelpPane(
53
+ runtime = createTestPluginRuntime(),
54
+ size = { width: 88, height: 36 },
55
+ ) {
56
+ if (testSetup) {
57
+ act(() => {
58
+ testSetup!.renderer.destroy();
59
+ });
60
+ testSetup = undefined;
61
+ }
62
+ return renderHelpPane(runtime, size);
63
+ }
64
+
41
65
  function withoutScrollbar(line: string) {
42
66
  const scrollThumb = String.fromCharCode(0x2588);
43
67
  return (line.endsWith(scrollThumb) ? line.slice(0, -1) : line).trimEnd();
44
68
  }
45
69
 
46
70
  describe("HelpPane", () => {
47
- test("puts section spacing before headings instead of after them", async () => {
71
+ test("starts on basics and puts section spacing before headings instead of after them", async () => {
48
72
  await renderHelpPane(createTestPluginRuntime(), { width: 88, height: 80 });
49
73
 
50
74
  const lines = testSetup!.captureCharFrame().split("\n").map(withoutScrollbar);
51
75
  const commandBarRow = lines.findIndex((line) => line.includes("Command Bar"));
52
- const templatesRow = lines.findIndex((line) => line.includes("Window Templates"));
53
-
76
+ const layoutRow = lines.findIndex((line) => line.includes("Layout Basics"));
77
+
78
+ expect(lines[0]).toContain("Basics");
79
+ expect(testSetup!.captureCharFrame()).toContain("Basics");
80
+ expect(testSetup!.captureCharFrame()).toContain("Functions");
81
+ expect(testSetup!.captureCharFrame()).toContain("Issues");
82
+ expect(testSetup!.captureCharFrame()).not.toContain("Issues/Debug");
83
+ expect(testSetup!.captureCharFrame()).not.toContain("Window Templates");
84
+ expect(testSetup!.captureCharFrame()).not.toContain("Search for a ticker or open the best matching security.");
54
85
  expect(commandBarRow).toBeGreaterThan(0);
55
- expect(templatesRow).toBeGreaterThan(commandBarRow);
86
+ expect(layoutRow).toBeGreaterThan(commandBarRow);
56
87
  expect(lines[commandBarRow - 1]?.trim()).toBe("");
57
- expect(lines[commandBarRow + 1]).toContain("Open or toggle the command bar");
58
- expect(lines[templatesRow - 1]?.trim()).toBe("");
59
- expect(lines[templatesRow + 1]?.trim()).not.toBe("");
88
+ expect(lines[commandBarRow + 1]).toContain("Open command mode");
89
+ expect(testSetup!.captureCharFrame()).toContain("Open ticker search directly.");
90
+ expect(testSetup!.captureCharFrame()).toContain("DES");
91
+ expect(testSetup!.captureCharFrame()).toContain("Clear command text.");
92
+ expect(testSetup!.captureCharFrame()).toContain("Delete the previous word");
93
+ expect(testSetup!.captureCharFrame()).toContain("Submit multiline command forms.");
94
+ expect(testSetup!.captureCharFrame()).toContain("close all floating panes");
95
+ expect(lines[layoutRow - 1]?.trim()).toBe("");
96
+ expect(lines[layoutRow + 1]?.trim()).not.toBe("");
60
97
  });
61
98
 
62
- test("lists core, plugin command, template, and keyboard shortcuts", async () => {
99
+ test("separates functions from keyboard shortcuts", async () => {
63
100
  setSharedRegistryForTests({
64
101
  commands: new Map([[
65
102
  "set-alert",
@@ -106,24 +143,52 @@ describe("HelpPane", () => {
106
143
  } as any);
107
144
 
108
145
  await renderHelpPane(createTestPluginRuntime(), { width: 88, height: 80 });
109
-
110
- const frame = testSetup!.captureCharFrame();
111
- expect(frame).toMatch(/AW\s+<ticker>/);
112
- expect(frame).toMatch(/SA\s+<symbol condition price>/);
113
- expect(frame).toMatch(/QQ\s+<tickers>/);
114
- expect(frame).toContain("Add Alert (Alerts)");
115
- expect(frame).toContain("Shift+X");
116
- expect(frame).toContain("Sync data (Gloom Cloud)");
117
- expect(frame).toContain("Ctrl+W");
118
- expect(frame).toContain("Ctrl+,");
119
- expect(frame).not.toContain("Ctrl+Shift+O");
120
- expect(frame).not.toContain("Pop the focused pane");
121
- expect(frame).not.toContain("Cmd+W");
122
- expect(frame).not.toContain("Cmd+,");
123
- expect(frame).not.toContain("Cmd+K");
124
- expect(frame).not.toContain("Cmd/Ctrl");
125
- expect(frame).not.toContain("CmdOrCtrl");
126
- expect(frame).not.toContain("Open ticker actions for the focused ticker.");
146
+ await moveHelpTabRight();
147
+
148
+ const functionsFrame = testSetup!.captureCharFrame();
149
+ expect(functionsFrame).toContain("Manage Plugins");
150
+ expect(functionsFrame).toMatch(/AW\s+<ticker>/);
151
+ expect(functionsFrame).toMatch(/SA\s+<symbol condition price>/);
152
+ expect(functionsFrame).toMatch(/QQ\s+<tickers>/);
153
+ expect(functionsFrame).toContain("Alerts");
154
+ expect(functionsFrame).toContain("Add Alert");
155
+ expect(functionsFrame).toContain("Shift+X");
156
+ expect(functionsFrame).toContain("Gloom Cloud");
157
+ expect(functionsFrame).toContain("Sync data");
158
+ expect(functionsFrame).toContain("Ticker Research");
159
+ expect(functionsFrame).toContain("Quote Monitor");
160
+ expect(functionsFrame).not.toContain("Add Alert (Alerts)");
161
+ expect(functionsFrame).not.toContain("Sync data (Gloom Cloud)");
162
+ expect(functionsFrame).not.toContain("Quote Monitor (Ticker Research)");
163
+ expect(functionsFrame).not.toContain("Ctrl+W");
164
+
165
+ await resetRenderedHelpPane(createTestPluginRuntime(), { width: 88, height: 80 });
166
+ await moveHelpTabRight(2);
167
+
168
+ const shortcutsFrame = testSetup!.captureCharFrame();
169
+ expect(shortcutsFrame).toContain("Ctrl+W");
170
+ expect(shortcutsFrame).toContain("Ctrl+Alt+W");
171
+ expect(shortcutsFrame).toContain("Ctrl+Shift+M");
172
+ expect(shortcutsFrame).toContain("Ctrl+Shift+R");
173
+ expect(shortcutsFrame).toContain("Ctrl+,");
174
+ expect(shortcutsFrame).toContain("Navigation");
175
+ expect(shortcutsFrame).toContain("Up/Down");
176
+ expect(shortcutsFrame).toContain("j/k");
177
+ expect(shortcutsFrame).toContain("Left/Right");
178
+ expect(shortcutsFrame).toContain("h/l");
179
+ expect(shortcutsFrame).toContain("PageUp/PageDown");
180
+ expect(shortcutsFrame).toContain("Window Mode");
181
+ expect(shortcutsFrame).toContain("h/j/k/l");
182
+ expect(shortcutsFrame).toContain("Close all floating panes.");
183
+ expect(shortcutsFrame).not.toContain("AW");
184
+ expect(shortcutsFrame).not.toContain("Ctrl+Shift+O");
185
+ expect(shortcutsFrame).not.toContain("Pop the focused pane");
186
+ expect(shortcutsFrame).not.toContain("Cmd+W");
187
+ expect(shortcutsFrame).not.toContain("Cmd+,");
188
+ expect(shortcutsFrame).not.toContain("Cmd+K");
189
+ expect(shortcutsFrame).not.toContain("Cmd/Ctrl");
190
+ expect(shortcutsFrame).not.toContain("CmdOrCtrl");
191
+ expect(shortcutsFrame).not.toContain("Open ticker actions for the focused ticker.");
127
192
  });
128
193
 
129
194
  test("keeps shortcuts and descriptions on the same row when narrow", async () => {
@@ -131,14 +196,18 @@ describe("HelpPane", () => {
131
196
 
132
197
  const lines = testSetup!.captureCharFrame().split("\n").map(withoutScrollbar);
133
198
  const closeCommandRow = lines.findIndex((line) => line.includes("Close the command bar."));
134
- const securityDetailsRow = lines.findIndex((line) => line.includes("Open security details"));
135
199
 
136
200
  expect(closeCommandRow).toBeGreaterThanOrEqual(0);
137
201
  expect(lines[closeCommandRow]).toContain("Esc");
138
202
  expect(lines[closeCommandRow]).toContain("`");
203
+
204
+ await moveHelpTabRight();
205
+
206
+ const functionLines = testSetup!.captureCharFrame().split("\n").map(withoutScrollbar);
207
+ const securityDetailsRow = functionLines.findIndex((line) => line.includes("Open security details"));
139
208
  expect(securityDetailsRow).toBeGreaterThanOrEqual(0);
140
- expect(lines[securityDetailsRow]).toContain("DES");
141
- expect(lines[securityDetailsRow]).toContain("<ticker>");
209
+ expect(functionLines[securityDetailsRow]).toContain("DES");
210
+ expect(functionLines[securityDetailsRow]).toContain("<ticker>");
142
211
  });
143
212
 
144
213
  test("opens the debug log from the mouse action", async () => {
@@ -149,8 +218,14 @@ describe("HelpPane", () => {
149
218
  });
150
219
 
151
220
  await renderHelpPane(runtime);
221
+ await moveHelpTabRight(3);
222
+
223
+ const frame = testSetup!.captureCharFrame();
224
+ expect(frame).toContain("GitHub Issues");
225
+ expect(frame).not.toContain("Issues/Debug");
226
+ expect(frame).not.toContain("plugin,\n");
152
227
 
153
- const lines = testSetup!.captureCharFrame().split("\n");
228
+ const lines = frame.split("\n");
154
229
  const row = lines.findIndex((line) => line.includes("Open Debug Log"));
155
230
  const col = lines[row]?.indexOf("Open Debug Log") ?? -1;
156
231