gloomberb 0.9.2 → 0.9.3

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 +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  29. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  30. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  31. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  32. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  33. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  34. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  35. package/src/components/chart/core/pane-settings.test.ts +27 -0
  36. package/src/components/chart/core/pane-settings.ts +60 -1
  37. package/src/components/chart/core/pointer.ts +2 -0
  38. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  39. package/src/components/chart/stock-chart/settings.ts +7 -3
  40. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  41. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  42. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  43. package/src/components/data-table/view.tsx +13 -2
  44. package/src/components/feed-data-table/stack-view.tsx +9 -1
  45. package/src/components/index.ts +1 -1
  46. package/src/components/input-search-bar.test.tsx +103 -0
  47. package/src/components/input-search-bar.tsx +16 -0
  48. package/src/components/layout/shell/active-drag.ts +5 -1
  49. package/src/components/layout/shell/drag/runtime.ts +21 -3
  50. package/src/components/layout/shell/fullscreen.ts +10 -0
  51. package/src/components/layout/shell/index.test.tsx +395 -2
  52. package/src/components/layout/shell/index.tsx +128 -2
  53. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  54. package/src/components/layout/shell/pane/layers.tsx +26 -14
  55. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  56. package/src/components/layout/shell/shortcuts.ts +4 -0
  57. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  58. package/src/components/layout/shell/window-mode/index.ts +11 -3
  59. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  60. package/src/components/layout/status-bar.test.tsx +105 -0
  61. package/src/components/layout/status-bar.tsx +32 -6
  62. package/src/components/layout/transient-layout.tsx +57 -0
  63. package/src/components/layout/window-edit/mode.test.ts +76 -5
  64. package/src/components/layout/window-edit/mode.ts +47 -2
  65. package/src/components/layout/window-edit/presentation.ts +2 -1
  66. package/src/components/price-performance.tsx +9 -1
  67. package/src/core/app-services.ts +7 -4
  68. package/src/data/config/store/index.ts +1 -2
  69. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  70. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  71. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  72. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  73. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  74. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  75. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  76. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  77. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  78. package/src/plugins/builtin/shared/read-state.ts +43 -0
  79. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  80. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  81. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  82. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  83. package/src/plugins/builtin/substack/pane.tsx +8 -3
  84. package/src/plugins/builtin/substack/read-state.ts +55 -0
  85. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  86. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  87. package/src/plugins/pane-manager/docking.ts +9 -2
  88. package/src/plugins/pane-manager/queries.ts +8 -2
  89. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  90. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  91. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  92. package/src/plugins/prediction-markets/pane.tsx +21 -1
  93. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  94. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  95. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  97. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  98. package/src/renderers/electrobun/bun/index.ts +13 -0
  99. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  100. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  101. package/src/renderers/electrobun/view/app-services.ts +6 -4
  102. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  103. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  104. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  105. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  106. package/src/renderers/opentui/start.tsx +13 -5
  107. package/src/state/app/input-capture.ts +12 -0
  108. package/src/types/plugin.ts +28 -0
  109. package/src/utils/cli-output.ts +6 -0
  110. package/src/utils/search-focus-navigation.ts +24 -0
  111. package/src/version.ts +1 -1
@@ -5,6 +5,8 @@ import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../types
5
5
  import type { AppNotificationRequest } from "../../types/plugin";
6
6
  import { StatusBar } from "./status-bar";
7
7
  import { setSharedRegistryForTests } from "../../plugins/registry";
8
+ import { useEffect, useState } from "react";
9
+ import { TransientLayoutProvider, useTransientLayout } from "./transient-layout";
8
10
 
9
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
12
 
@@ -17,6 +19,37 @@ afterEach(() => {
17
19
  });
18
20
 
19
21
  describe("StatusBar", () => {
22
+ function SeedTransientLayout({
23
+ onActivate,
24
+ onDeactivate,
25
+ onExit,
26
+ }: {
27
+ onActivate?: () => void;
28
+ onDeactivate?: () => void;
29
+ onExit?: () => void;
30
+ }) {
31
+ const { setTransientLayout } = useTransientLayout();
32
+ const [active, setActive] = useState(true);
33
+ useEffect(() => {
34
+ setTransientLayout({
35
+ id: "pane-focus",
36
+ label: "^F Focus",
37
+ active,
38
+ onActivate: () => {
39
+ onActivate?.();
40
+ setActive(true);
41
+ },
42
+ onDeactivate: () => {
43
+ onDeactivate?.();
44
+ setActive(false);
45
+ },
46
+ onExit,
47
+ });
48
+ return () => setTransientLayout(null);
49
+ }, [active, onActivate, onDeactivate, onExit, setTransientLayout]);
50
+ return null;
51
+ }
52
+
20
53
  test("opens the command bar from the shortcut hint", async () => {
21
54
  const config = createDefaultConfig("/tmp/gloomberb-test");
22
55
  config.layouts = [{ name: "Home", layout: cloneLayout(config.layout) }];
@@ -45,6 +78,78 @@ describe("StatusBar", () => {
45
78
  expect(actions).toContainEqual({ type: "SET_COMMAND_BAR", open: true, query: "" });
46
79
  });
47
80
 
81
+ test("shows a transient focus layout tab without replacing saved layouts", async () => {
82
+ const config = createDefaultConfig("/tmp/gloomberb-transient-layout-test");
83
+ config.layouts = [
84
+ { name: "Default", layout: cloneLayout(config.layout) },
85
+ { name: "Monitor", layout: cloneLayout(config.layout) },
86
+ ];
87
+ const state = {
88
+ ...createInitialState(config),
89
+ statusBarVisible: true,
90
+ };
91
+ const actions: Array<{ type: string; index?: number }> = [];
92
+ let activateCount = 0;
93
+ let deactivateCount = 0;
94
+ let exitCount = 0;
95
+ const handleActivate = () => { activateCount += 1; };
96
+ const handleDeactivate = () => { deactivateCount += 1; };
97
+ const handleExit = () => { exitCount += 1; };
98
+
99
+ testSetup = await testRender(
100
+ <AppContext value={{ state, dispatch: (action) => actions.push(action as { type: string; index?: number }) }}>
101
+ <TransientLayoutProvider>
102
+ <SeedTransientLayout
103
+ onActivate={handleActivate}
104
+ onDeactivate={handleDeactivate}
105
+ onExit={handleExit}
106
+ />
107
+ <StatusBar />
108
+ </TransientLayoutProvider>
109
+ </AppContext>,
110
+ { width: 120, height: 1 },
111
+ );
112
+
113
+ await testSetup.renderOnce();
114
+ await testSetup.renderOnce();
115
+
116
+ const frame = testSetup.captureCharFrame();
117
+ expect(frame).toContain("^1 Default");
118
+ expect(frame).toContain("^2 Monitor");
119
+ expect(frame).toContain("^F Focus");
120
+
121
+ const monitorX = frame.split("\n")[0]?.indexOf("^2 Monitor") ?? -1;
122
+ expect(monitorX).toBeGreaterThanOrEqual(0);
123
+
124
+ await testSetup.mockMouse.click(monitorX + 1, 0);
125
+ await testSetup.renderOnce();
126
+ await testSetup.renderOnce();
127
+
128
+ expect(deactivateCount).toBe(1);
129
+ expect(exitCount).toBe(0);
130
+ expect(actions).toContainEqual({ type: "SWITCH_LAYOUT", index: 1 });
131
+
132
+ const afterSwitchFrame = testSetup.captureCharFrame();
133
+ expect(afterSwitchFrame).toContain("^F Focus");
134
+
135
+ const focusX = afterSwitchFrame.split("\n")[0]?.indexOf("^F Focus") ?? -1;
136
+ expect(focusX).toBeGreaterThanOrEqual(0);
137
+
138
+ await testSetup.mockMouse.click(focusX + 1, 0);
139
+ await testSetup.renderOnce();
140
+
141
+ expect(activateCount).toBe(1);
142
+
143
+ const activeFocusFrame = testSetup.captureCharFrame();
144
+ const activeFocusX = activeFocusFrame.split("\n")[0]?.indexOf("^F Focus") ?? -1;
145
+ expect(activeFocusX).toBeGreaterThanOrEqual(0);
146
+
147
+ await testSetup.mockMouse.click(activeFocusX + 1, 0);
148
+ await testSetup.renderOnce();
149
+
150
+ expect(exitCount).toBe(1);
151
+ });
152
+
48
153
  test("shows a gridlock tip after a corner snap and runs gridlock on click", async () => {
49
154
  const config = createDefaultConfig("/tmp/gloomberb-test");
50
155
  const floatingLayout = cloneLayout(config.layout);
@@ -16,6 +16,7 @@ import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
16
16
  import { PluginSlot } from "../../react/plugins/plugin-slot";
17
17
  import type { ContextMenuItem } from "../../types/context-menu";
18
18
  import { Tabs } from "../ui/tabs";
19
+ import { useTransientLayout } from "./transient-layout";
19
20
 
20
21
  const GRIDLOCK_TIP_DURATION_MS = 60_000;
21
22
 
@@ -31,6 +32,7 @@ type LayoutTabItem = {
31
32
 
32
33
  type StatusBarViewProps = {
33
34
  activeLayoutIdx: number;
35
+ activeLayoutValue: string;
34
36
  dismissGridlockTip: (event?: StatusBarEvent) => void;
35
37
  handleGridlockTip: (event?: StatusBarEvent) => void;
36
38
  handleLayoutSelect: (value: string) => void;
@@ -62,18 +64,40 @@ export function StatusBar() {
62
64
  const statusBarVisible = useAppSelector(selectStatusBarVisible);
63
65
  const gridlockTipVisible = useAppSelector(selectGridlockTipVisible);
64
66
  const gridlockTipSequence = useAppSelector(selectGridlockTipSequence);
67
+ const { transientLayout } = useTransientLayout();
65
68
  const [hoveredControl, setHoveredControl] = useState<string | null>(null);
66
69
 
67
- const hasMultipleLayouts = layouts.length > 1;
70
+ const hasMultipleLayouts = layouts.length > 1 || !!transientLayout;
68
71
  const showGridlockTip = gridlockTipVisible && !!registry;
69
- const layoutTabs = layouts.map((layout, index) => ({
72
+ const savedLayoutTabs = layouts.map((layout, index) => ({
70
73
  label: `^${index + 1} ${truncate(layout.name, 14)}`,
71
74
  value: String(index),
72
75
  }));
76
+ const layoutTabs = transientLayout
77
+ ? [
78
+ ...savedLayoutTabs,
79
+ {
80
+ label: transientLayout.label,
81
+ value: transientLayout.id,
82
+ },
83
+ ]
84
+ : savedLayoutTabs;
73
85
  const layoutTabsWidth = layoutTabs.reduce((sum, tab) => sum + tab.label.length + 2, 0);
86
+ const activeLayoutValue = transientLayout?.active ? transientLayout.id : String(activeLayoutIdx);
74
87
  const handleLayoutSelect = (value: string) => {
88
+ if (value === transientLayout?.id) {
89
+ if (transientLayout.active) {
90
+ transientLayout.onExit?.();
91
+ } else {
92
+ transientLayout.onActivate?.();
93
+ }
94
+ return;
95
+ }
75
96
  const index = Number(value);
76
97
  if (!Number.isInteger(index) || index < 0 || index >= layouts.length) return;
98
+ if (transientLayout?.active) {
99
+ transientLayout.onDeactivate?.();
100
+ }
77
101
  dispatch({ type: "SWITCH_LAYOUT", index });
78
102
  };
79
103
 
@@ -184,11 +208,12 @@ export function StatusBar() {
184
208
  );
185
209
  }, [activeLayoutIdx, layoutContextMenuItems, layouts, showContextMenu]);
186
210
  const handleLayoutTabContextMenu = useCallback((value: string, event: any) => {
211
+ if (value === transientLayout?.id) return;
187
212
  const index = Number(value);
188
213
  if (!Number.isInteger(index) || index < 0 || index >= layouts.length) return;
189
214
  if (event?.type !== "contextmenu" && event?.button === 2 && nativeContextMenu === true) return;
190
215
  void openLayoutContextMenu(index, event);
191
- }, [layouts.length, nativeContextMenu, openLayoutContextMenu]);
216
+ }, [layouts.length, nativeContextMenu, openLayoutContextMenu, transientLayout?.id]);
192
217
  const layoutTabItems = layoutTabs.map((tab) => ({
193
218
  ...tab,
194
219
  onContextMenu: handleLayoutTabContextMenu,
@@ -198,6 +223,7 @@ export function StatusBar() {
198
223
 
199
224
  const viewProps: StatusBarViewProps = {
200
225
  activeLayoutIdx,
226
+ activeLayoutValue,
201
227
  dismissGridlockTip,
202
228
  handleGridlockTip,
203
229
  handleLayoutSelect,
@@ -272,7 +298,7 @@ function TerminalStatusBar({
272
298
  }
273
299
 
274
300
  function StatusBarLayoutControl({
275
- activeLayoutIdx,
301
+ activeLayoutValue,
276
302
  handleLayoutSelect,
277
303
  hasMultipleLayouts,
278
304
  hoveredControl,
@@ -283,7 +309,7 @@ function StatusBarLayoutControl({
283
309
  setHoveredControl,
284
310
  }: Pick<
285
311
  StatusBarViewProps,
286
- | "activeLayoutIdx"
312
+ | "activeLayoutValue"
287
313
  | "handleLayoutSelect"
288
314
  | "hasMultipleLayouts"
289
315
  | "hoveredControl"
@@ -303,7 +329,7 @@ function StatusBarLayoutControl({
303
329
  <Box width={layoutTabsWidth} height={1}>
304
330
  <Tabs
305
331
  tabs={layoutTabItems}
306
- activeValue={String(activeLayoutIdx)}
332
+ activeValue={activeLayoutValue}
307
333
  onSelect={handleLayoutSelect}
308
334
  compact
309
335
  variant="pill"
@@ -0,0 +1,57 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
2
+
3
+ export interface TransientLayoutState {
4
+ id: "pane-focus";
5
+ label: string;
6
+ active: boolean;
7
+ onActivate?: () => void;
8
+ onDeactivate?: () => void;
9
+ onExit?: () => void;
10
+ }
11
+
12
+ interface TransientLayoutContextValue {
13
+ transientLayout: TransientLayoutState | null;
14
+ setTransientLayout: (layout: TransientLayoutState | null) => void;
15
+ }
16
+
17
+ const noop = () => {};
18
+
19
+ const TransientLayoutContext = createContext<TransientLayoutContextValue>({
20
+ transientLayout: null,
21
+ setTransientLayout: noop,
22
+ });
23
+
24
+ function sameTransientLayout(
25
+ left: TransientLayoutState | null,
26
+ right: TransientLayoutState | null,
27
+ ): boolean {
28
+ if (left === right) return true;
29
+ if (!left || !right) return false;
30
+ return left.id === right.id
31
+ && left.label === right.label
32
+ && left.active === right.active
33
+ && left.onActivate === right.onActivate
34
+ && left.onDeactivate === right.onDeactivate
35
+ && left.onExit === right.onExit;
36
+ }
37
+
38
+ export function TransientLayoutProvider({ children }: { children: ReactNode }) {
39
+ const [transientLayout, setTransientLayoutState] = useState<TransientLayoutState | null>(null);
40
+ const setTransientLayout = useCallback((layout: TransientLayoutState | null) => {
41
+ setTransientLayoutState((current) => (sameTransientLayout(current, layout) ? current : layout));
42
+ }, []);
43
+ const value = useMemo(() => ({
44
+ transientLayout,
45
+ setTransientLayout,
46
+ }), [setTransientLayout, transientLayout]);
47
+
48
+ return (
49
+ <TransientLayoutContext.Provider value={value}>
50
+ {children}
51
+ </TransientLayoutContext.Provider>
52
+ );
53
+ }
54
+
55
+ export function useTransientLayout(): TransientLayoutContextValue {
56
+ return useContext(TransientLayoutContext);
57
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { LayoutConfig } from "../../../types/config";
3
- import { cycleWindowEditPane, getWindowEditPaneIds, type WindowEditState } from "./mode";
3
+ import {
4
+ cycleWindowEditPane,
5
+ cycleWindowEditTarget,
6
+ getWindowEditPaneIds,
7
+ type WindowEditState,
8
+ } from "./mode";
9
+ import { resolveWindowEditDockMovePreview } from "./presentation";
4
10
 
5
11
  const bounds = { x: 0, y: 0, width: 120, height: 40 };
6
12
 
@@ -38,18 +44,83 @@ describe("window edit mode", () => {
38
44
  dirty: false,
39
45
  };
40
46
 
41
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
47
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
42
48
  expect(state.paneId).toBe("dock-b");
43
49
 
44
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
50
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
45
51
  expect(state.paneId).toBe("float-a");
46
52
  expect(state.previewLayout.floating.find((entry) => entry.instanceId === "float-a")?.zIndex)
47
53
  .toBeGreaterThan(state.previewLayout.floating.find((entry) => entry.instanceId === "float-b")?.zIndex ?? 0);
48
54
 
49
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
55
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
50
56
  expect(state.paneId).toBe("float-b");
51
57
 
52
- state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout), bounds, {}, 1);
58
+ state = cycleWindowEditPane(state, getWindowEditPaneIds(state.previewLayout, bounds, {}), bounds, {}, 1);
53
59
  expect(state.paneId).toBe("dock-a");
54
60
  });
61
+
62
+ test("cycles dock move targets without changing the selected window", () => {
63
+ const layout: LayoutConfig = {
64
+ dockRoot: {
65
+ kind: "split",
66
+ axis: "horizontal",
67
+ ratio: 0.5,
68
+ first: { kind: "pane", instanceId: "source" },
69
+ second: {
70
+ kind: "split",
71
+ axis: "vertical",
72
+ ratio: 0.5,
73
+ first: { kind: "pane", instanceId: "target-a" },
74
+ second: { kind: "pane", instanceId: "target-b" },
75
+ },
76
+ },
77
+ instances: [
78
+ pane("source"),
79
+ pane("target-a"),
80
+ pane("target-b"),
81
+ ],
82
+ floating: [],
83
+ detached: [],
84
+ };
85
+ const state: WindowEditState = {
86
+ paneId: "source",
87
+ previewLayout: layout,
88
+ mode: "move",
89
+ focus: { kind: "dock-move", targetId: "target-a", position: "right" },
90
+ dirty: false,
91
+ };
92
+
93
+ const next = cycleWindowEditTarget(state, bounds, { reserveDividerGutters: true }, 1);
94
+
95
+ expect(next.paneId).toBe("source");
96
+ expect(next.focus).toEqual({ kind: "dock-move", targetId: "target-b", position: "right" });
97
+ });
98
+
99
+ test("uses dock geometry options for dock move preview rectangles", () => {
100
+ const layout: LayoutConfig = {
101
+ dockRoot: {
102
+ kind: "split",
103
+ axis: "horizontal",
104
+ ratio: 0.5,
105
+ first: { kind: "pane", instanceId: "left" },
106
+ second: { kind: "pane", instanceId: "right" },
107
+ },
108
+ instances: [
109
+ pane("left"),
110
+ pane("right"),
111
+ ],
112
+ floating: [],
113
+ detached: [],
114
+ };
115
+ const state: WindowEditState = {
116
+ paneId: "left",
117
+ previewLayout: layout,
118
+ mode: "move",
119
+ focus: { kind: "dock-move", targetId: "right", position: "right" },
120
+ dirty: false,
121
+ };
122
+
123
+ expect(resolveWindowEditDockMovePreview(state, bounds, { reserveDividerGutters: true })?.rect)
124
+ .toEqual({ x: 61, y: 0, width: 59, height: 40 });
125
+ });
55
126
  });
@@ -226,12 +226,57 @@ export function cycleWindowEditFocus(
226
226
  return targets[(currentIndex + delta + targets.length) % targets.length] ?? targets[0]!;
227
227
  }
228
228
 
229
- export function getWindowEditPaneIds(layout: LayoutConfig): string[] {
230
- const dockedIds = getDockLeafLayouts(layout, { x: 0, y: 0, width: 120, height: 40 }).map((entry) => entry.instanceId);
229
+ function getWindowEditDockPaneIds(
230
+ layout: LayoutConfig,
231
+ bounds: LayoutBounds,
232
+ dockGeometryOptions: DockGeometryOptions,
233
+ ): string[] {
234
+ return getDockLeafLayouts(layout, bounds, dockGeometryOptions).map((entry) => entry.instanceId);
235
+ }
236
+
237
+ export function getWindowEditPaneIds(
238
+ layout: LayoutConfig,
239
+ bounds: LayoutBounds,
240
+ dockGeometryOptions: DockGeometryOptions,
241
+ ): string[] {
242
+ const dockedIds = getWindowEditDockPaneIds(layout, bounds, dockGeometryOptions);
231
243
  const floatingIds = layout.floating.map((entry) => entry.instanceId);
232
244
  return [...dockedIds, ...floatingIds];
233
245
  }
234
246
 
247
+ export function cycleWindowEditTarget(
248
+ state: WindowEditState,
249
+ bounds: LayoutBounds,
250
+ dockGeometryOptions: DockGeometryOptions,
251
+ delta: 1 | -1,
252
+ ): WindowEditState {
253
+ if (state.mode !== "move" || state.focus.kind !== "dock-move") {
254
+ return state;
255
+ }
256
+
257
+ const targetIds = getWindowEditDockPaneIds(state.previewLayout, bounds, dockGeometryOptions)
258
+ .filter((paneId) => paneId !== state.paneId);
259
+ if (targetIds.length === 0) {
260
+ return {
261
+ ...state,
262
+ focus: normalizeWindowEditFocus({ kind: "move" }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
263
+ };
264
+ }
265
+
266
+ const currentIndex = targetIds.indexOf(state.focus.targetId);
267
+ const startIndex = currentIndex >= 0
268
+ ? currentIndex
269
+ : delta === 1
270
+ ? -1
271
+ : 0;
272
+ const targetId = targetIds[(startIndex + delta + targetIds.length) % targetIds.length] ?? targetIds[0]!;
273
+ return {
274
+ ...state,
275
+ focus: normalizeWindowEditFocus({ ...state.focus, targetId }, state.previewLayout, state.paneId, state.mode, bounds, dockGeometryOptions),
276
+ notice: undefined,
277
+ };
278
+ }
279
+
235
280
  export function raiseWindowEditPane(layout: LayoutConfig, paneId: string): LayoutConfig {
236
281
  const floating = layout.floating.find((entry) => entry.instanceId === paneId);
237
282
  if (!floating) return layout;
@@ -73,7 +73,7 @@ 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/exit Esc exit/cancel";
76
+ return "arrows/hjkl choose side Tab/w target m window d float r resize Enter commit/exit Esc exit/cancel";
77
77
  }
78
78
  return "Tab/w window d dock/float r resize arrows/hjkl target/move Shift fast Enter commit/exit Esc exit/cancel";
79
79
  }
@@ -100,6 +100,7 @@ export function resolveWindowEditDockMovePreview(
100
100
  state.paneId,
101
101
  leafDropTarget(focus.targetId, focus.position),
102
102
  bounds,
103
+ dockGeometryOptions,
103
104
  );
104
105
  if (!simulation.previewRect) return null;
105
106
  return {
@@ -3,6 +3,11 @@ import { blendHex, colors, priceColor } from "../theme/colors";
3
3
  import { displayWidth, formatPercent, padTo } from "../utils/format";
4
4
  import type { PriceReturnField } from "../market-data/performance";
5
5
 
6
+ type MouseInteractionEvent = {
7
+ stopPropagation?: () => void;
8
+ preventDefault?: () => void;
9
+ };
10
+
6
11
  const STRIP_COLUMN_GAP = 1;
7
12
  const RETURN_CELL_MIN_WIDTH = 7;
8
13
  const SYMBOL_COLUMN_MIN_WIDTH = 6;
@@ -100,12 +105,14 @@ function chooseComparisonFieldIds(width: number, availableIds: readonly string[]
100
105
 
101
106
  export function PriceReturnTable({
102
107
  height,
108
+ onFocusInteraction,
103
109
  onOpenSymbol,
104
110
  onSelectSymbol,
105
111
  rows,
106
112
  width,
107
113
  }: {
108
114
  height: number;
115
+ onFocusInteraction?: (event: MouseInteractionEvent | null | undefined) => void;
109
116
  onOpenSymbol: (symbol: string) => void;
110
117
  onSelectSymbol: (symbol: string) => void;
111
118
  rows: PriceReturnTableRow[];
@@ -151,7 +158,8 @@ export function PriceReturnTable({
151
158
  width={width}
152
159
  backgroundColor={row.selected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
153
160
  onMouseMove={() => onSelectSymbol(row.symbol)}
154
- onMouseDown={() => {
161
+ onMouseDown={(event: any) => {
162
+ onFocusInteraction?.(event);
155
163
  onSelectSymbol(row.symbol);
156
164
  onOpenSymbol(row.symbol);
157
165
  }}
@@ -12,7 +12,7 @@ import { assetDataProvider, newsProvider } from "../capabilities";
12
12
  import type { AppConfig } from "../types/config";
13
13
  import type { DataProvider } from "../types/data-provider";
14
14
  import { debugLog } from "../utils/debug-log";
15
- import { measurePerf } from "../utils/perf-marks";
15
+ import { measurePerf, measurePerfAsync } from "../utils/perf-marks";
16
16
  import { setIbkrPortfolioPerformanceResourceStore } from "../plugins/ibkr/portfolio-performance";
17
17
 
18
18
  const servicesLog = debugLog.createLogger("services");
@@ -25,6 +25,7 @@ export interface AppServices {
25
25
  marketData: MarketDataCoordinator;
26
26
  pluginRegistry: PluginRegistry;
27
27
  newsService: NewsService;
28
+ ready: Promise<void>;
28
29
  destroy(): void;
29
30
  }
30
31
 
@@ -68,10 +69,11 @@ export function createAppServices({
68
69
  setSharedMarketDataCoordinator(marketData);
69
70
 
70
71
  const plugins = getLoadablePlugins(externalPlugins);
72
+ const pluginReadyPromises: Promise<void>[] = [];
71
73
  for (const plugin of plugins) {
72
- measurePerf("startup.services.register-plugin", () => {
73
- void pluginRegistry.register(plugin);
74
- }, { pluginId: plugin.id });
74
+ pluginReadyPromises.push(measurePerfAsync("startup.services.register-plugin", () => (
75
+ pluginRegistry.register(plugin)
76
+ ), { pluginId: plugin.id }));
75
77
  }
76
78
  measurePerf("startup.services.news-start", () => {
77
79
  newsService.start();
@@ -86,6 +88,7 @@ export function createAppServices({
86
88
  marketData,
87
89
  pluginRegistry,
88
90
  newsService,
91
+ ready: Promise.all(pluginReadyPromises).then(() => {}),
89
92
  destroy() {
90
93
  setSharedMarketDataCoordinator(null);
91
94
  setSharedNewsService(null);
@@ -21,8 +21,7 @@ export function setConfigStoreHost(host: ConfigStoreHost | null): void {
21
21
 
22
22
  async function loadNodeHost(): Promise<ConfigStoreHost> {
23
23
  if (!nodeHostPromise) {
24
- const modulePath = "./node";
25
- nodeHostPromise = import(modulePath) as Promise<ConfigStoreHost>;
24
+ nodeHostPromise = import("./node") as Promise<ConfigStoreHost>;
26
25
  }
27
26
  return nodeHostPromise;
28
27
  }
@@ -267,6 +267,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
267
267
  inputRef={searchInputRef}
268
268
  onFocus={focusSearch}
269
269
  onBlur={blurSearch}
270
+ onNavigateDown={blurSearch}
270
271
  onQueryChange={updateFeedQuery}
271
272
  />
272
273
  ) : null;
@@ -309,6 +310,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
309
310
  load={loadActiveFeed}
310
311
  onResult={markFeedResult}
311
312
  onError={markFeedError}
313
+ onFocusSearch={focusSearch}
312
314
  emptyStateTitle={searchEnabled ? "No tweets" : "Enter a search query"}
313
315
  emptyStateHint={searchEnabled ? activeFeedQuery : undefined}
314
316
  />
@@ -15,6 +15,7 @@ export function TwitterFeedSearchBar({
15
15
  inputRef,
16
16
  onFocus,
17
17
  onBlur,
18
+ onNavigateDown,
18
19
  onQueryChange,
19
20
  }: {
20
21
  feed: TwitterFeed;
@@ -25,6 +26,7 @@ export function TwitterFeedSearchBar({
25
26
  inputRef: RefObject<InputRenderable | null>;
26
27
  onFocus: () => void;
27
28
  onBlur: () => void;
29
+ onNavigateDown?: () => void;
28
30
  onQueryChange: (feedId: string, query: string) => void;
29
31
  }) {
30
32
  const updateQuery = useCallback((value: string) => {
@@ -43,6 +45,7 @@ export function TwitterFeedSearchBar({
43
45
  debounceMs={TWEET_SEARCH_DEBOUNCE_MS}
44
46
  onFocus={onFocus}
45
47
  onBlur={onBlur}
48
+ onNavigateDown={onNavigateDown}
46
49
  onQueryChange={updateQuery}
47
50
  />
48
51
  );
@@ -6,6 +6,7 @@ import {
6
6
  usePaneFooter,
7
7
  type DataTableCell,
8
8
  type DataTableKeyEvent,
9
+ type DataTableRootKeyContext,
9
10
  } from "../../../components";
10
11
  import { TickerBadgeText } from "../../../components/ticker/badge/text";
11
12
  import { RemoteImage } from "../../../components/ui";
@@ -15,6 +16,7 @@ import type { CloudTweetPayload, CloudTweetSearchResponse } from "../../../api-c
15
16
  import { formatTimeAgo } from "../../../utils/format";
16
17
  import { colors } from "../../../theme/colors";
17
18
  import { CloudAuthNotice } from "../cloud/auth-actions";
19
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
18
20
  import {
19
21
  buildTweetColumns,
20
22
  formatMetric,
@@ -152,6 +154,7 @@ export function TweetSearchTable({
152
154
  load,
153
155
  onResult,
154
156
  onError,
157
+ onFocusSearch,
155
158
  emptyStateTitle,
156
159
  emptyStateHint,
157
160
  }: {
@@ -165,6 +168,7 @@ export function TweetSearchTable({
165
168
  load: () => Promise<CloudTweetSearchResponse>;
166
169
  onResult?: (result: CloudTweetSearchResponse) => void;
167
170
  onError?: (message: string) => void;
171
+ onFocusSearch?: () => void;
168
172
  emptyStateTitle?: string;
169
173
  emptyStateHint?: string;
170
174
  }) {
@@ -219,13 +223,21 @@ export function TweetSearchTable({
219
223
  ));
220
224
  }, []);
221
225
 
222
- const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
226
+ const handleRootKeyDown = useCallback((
227
+ event: DataTableKeyEvent,
228
+ context: DataTableRootKeyContext,
229
+ ) => {
230
+ if (onFocusSearch && context.selectedIndex <= 0 && isPlainArrowUp(event)) {
231
+ stopSearchFocusNavigation(event);
232
+ onFocusSearch();
233
+ return true;
234
+ }
223
235
  if (event.name !== "r") return false;
224
236
  event.preventDefault?.();
225
237
  event.stopPropagation?.();
226
238
  reload();
227
239
  return true;
228
- }, [reload]);
240
+ }, [onFocusSearch, reload]);
229
241
 
230
242
  const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
231
243
  if (event.name !== "o") return false;