gloomberb 0.4.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -51,7 +51,7 @@ import { setLayoutManagerDispatch } from "./plugins/builtin/layout-manager";
51
51
  import { getLoadablePlugins } from "./plugins/catalog";
52
52
  import { saveConfig } from "./data/config-store";
53
53
  import { Toaster, toast } from "@opentui-ui/toast/react";
54
- import { canSelfUpdate, checkForUpdateDetailed, performUpdate } from "./updater";
54
+ import { canSelfUpdate, checkForUpdateDetailed, performUpdate, type ReleaseInfo } from "./updater";
55
55
  import { VERSION } from "./version";
56
56
  import { join } from "path";
57
57
  import {
@@ -525,6 +525,13 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
525
525
  }
526
526
  }, [state.config.brokerInstances, importBrokerPositions]);
527
527
 
528
+ const startUpdate = useCallback((release: ReleaseInfo) => {
529
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
530
+ void performUpdate(release, (progress) => {
531
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress });
532
+ });
533
+ }, [dispatch]);
534
+
528
535
  const runUpdateCheck = useCallback(async (manual = false) => {
529
536
  if (manual) {
530
537
  dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
@@ -566,6 +573,12 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
566
573
  void runUpdateCheck(false);
567
574
  }, [runUpdateCheck]);
568
575
 
576
+ useEffect(() => {
577
+ if (!state.updateAvailable || state.updateProgress || state.updateCheckInProgress) return;
578
+ if (!canSelfUpdate(state.updateAvailable)) return;
579
+ startUpdate(state.updateAvailable);
580
+ }, [startUpdate, state.updateAvailable, state.updateCheckInProgress, state.updateProgress]);
581
+
569
582
  // Load tickers on mount
570
583
  useEffect(() => {
571
584
  if (state.initialized || (globalThis as any).__gloomInitStarted) return;
@@ -1172,9 +1185,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
1172
1185
  dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
1173
1186
  }
1174
1187
  } else if (event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
1175
- performUpdate(state.updateAvailable, (progress) => {
1176
- dispatch({ type: "SET_UPDATE_PROGRESS", progress });
1177
- });
1188
+ startUpdate(state.updateAvailable);
1178
1189
  } else {
1179
1190
  // Plugin keyboard shortcuts (built-ins take priority)
1180
1191
  const disabledPlugins = new Set(state.config.disabledPlugins || []);
@@ -1278,6 +1289,7 @@ export function App({
1278
1289
  useEffect(() => {
1279
1290
  return () => {
1280
1291
  setSharedMarketDataCoordinator(null);
1292
+ services.pluginRegistry.destroy();
1281
1293
  services.persistence.close();
1282
1294
  };
1283
1295
  }, [services]);
@@ -2488,6 +2488,10 @@ export function CommandBar({
2488
2488
  return name ? `in "${name}"` : command.description;
2489
2489
  }
2490
2490
  case "check-for-updates":
2491
+ if (state.updateProgress?.phase === "downloading") return `Downloading v${state.updateAvailable?.version}: ${state.updateProgress.percent ?? 0}%`;
2492
+ if (state.updateProgress?.phase === "replacing") return "Installing update";
2493
+ if (state.updateProgress?.phase === "done") return "Update installed - restart to apply";
2494
+ if (state.updateProgress?.phase === "error") return `Update failed: ${state.updateProgress.error}`;
2491
2495
  if (state.updateCheckInProgress) return "Checking GitHub releases now";
2492
2496
  if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
2493
2497
  if (state.updateNotice) return state.updateNotice;
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { colors, floatingPaneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
+ import { getPaneBodyHeight } from "./pane-sizing";
4
5
 
5
6
  interface FloatingPaneWrapperProps {
6
7
  title: string;
@@ -20,6 +21,7 @@ export function FloatingPaneWrapper({
20
21
  title, x, y, width, height, zIndex, focused, showActions = false, onMouseMove, children,
21
22
  }: FloatingPaneWrapperProps) {
22
23
  const bg = floatingPaneBg(focused);
24
+ const bodyHeight = getPaneBodyHeight(height);
23
25
 
24
26
  return (
25
27
  <box
@@ -36,7 +38,7 @@ export function FloatingPaneWrapper({
36
38
  <PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
37
39
 
38
40
  {/* Content */}
39
- <box flexGrow={1} overflow="hidden">
41
+ <box height={bodyHeight} overflow="hidden">
40
42
  {children}
41
43
  </box>
42
44
 
@@ -31,7 +31,7 @@ function UpdateStatus() {
31
31
  <box flexDirection="row" gap={1}>
32
32
  <spinner name="dots" color={colors.headerText} />
33
33
  <text fg={colors.headerText}>
34
- Downloading v{updateAvailable?.version}... {updateProgress.percent ?? 0}%
34
+ Downloading v{updateAvailable?.version}: {updateProgress.percent ?? 0}%
35
35
  </text>
36
36
  </box>
37
37
  );
@@ -45,7 +45,7 @@ function UpdateStatus() {
45
45
  );
46
46
  }
47
47
  if (updateProgress.phase === "done") {
48
- return <text fg={colors.headerText}>Update installed — restart to apply</text>;
48
+ return <text fg={colors.headerText}>Update installed, restart to apply</text>;
49
49
  }
50
50
  if (updateProgress.phase === "error") {
51
51
  return <text fg={colors.headerText}>Update failed: {updateProgress.error}</text>;
@@ -71,7 +71,7 @@ function UpdateStatus() {
71
71
  }
72
72
  return (
73
73
  <text fg={colors.headerText}>
74
- v{updateAvailable.version} available — press <span fg={colors.headerText}>u</span> to update
74
+ v{updateAvailable.version} available — starting download...
75
75
  </text>
76
76
  );
77
77
  }
@@ -0,0 +1,6 @@
1
+ const RESERVED_PANE_CHROME_ROWS = 2;
2
+
3
+ export function getPaneBodyHeight(height: number): number {
4
+ // Reserve the header row plus the bottom border/resize row.
5
+ return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
6
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { paneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
+ import { getPaneBodyHeight } from "./pane-sizing";
4
5
 
5
6
  interface PaneWrapperProps {
6
7
  title?: string;
@@ -26,6 +27,9 @@ export function PaneWrapper({
26
27
  children,
27
28
  }: PaneWrapperProps) {
28
29
  const bg = paneBg(focused);
30
+ const bodyHeight = typeof height === "number"
31
+ ? title ? getPaneBodyHeight(height) : height
32
+ : undefined;
29
33
 
30
34
  return (
31
35
  <box
@@ -41,7 +45,7 @@ export function PaneWrapper({
41
45
  {title && (
42
46
  <PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
43
47
  )}
44
- <box flexGrow={1} overflow="hidden">
48
+ <box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
45
49
  {children}
46
50
  </box>
47
51
  </box>
@@ -1,12 +1,14 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { testRender } from "@opentui/react/test-utils";
3
3
  import { DialogProvider } from "@opentui-ui/dialog/react";
4
+ import type { ReactNode } from "react";
4
5
  import { act, useReducer } from "react";
5
6
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
6
7
  import { cloneLayout, createDefaultConfig } from "../../types/config";
7
8
  import type { PluginRegistry } from "../../plugins/registry";
8
9
  import { setSharedRegistryForTests } from "../../plugins/registry";
9
10
  import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
11
+ import type { PaneProps } from "../../types/plugin";
10
12
  import { StatusBar } from "./status-bar";
11
13
  import { Header } from "./header";
12
14
  import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
@@ -26,7 +28,9 @@ afterEach(() => {
26
28
  setSharedRegistryForTests(undefined);
27
29
  });
28
30
 
29
- function createShellPluginRegistry(): PluginRegistry {
31
+ function createShellPluginRegistry(options?: {
32
+ tickerDetailComponent?: (props: PaneProps) => ReactNode;
33
+ }): PluginRegistry {
30
34
  return {
31
35
  panes: new Map([
32
36
  ["portfolio-list", {
@@ -38,7 +42,7 @@ function createShellPluginRegistry(): PluginRegistry {
38
42
  ["ticker-detail", {
39
43
  id: "ticker-detail",
40
44
  name: "Ticker Detail",
41
- component: () => <text>Ticker Detail Body</text>,
45
+ component: options?.tickerDetailComponent ?? (() => <text>Ticker Detail Body</text>),
42
46
  defaultPosition: "right",
43
47
  defaultMode: "floating",
44
48
  }],
@@ -160,15 +164,18 @@ function createHeaderDataProvider(): DataProvider {
160
164
 
161
165
  function HeaderHarness({
162
166
  updateAvailable = null,
167
+ updateProgress = null,
163
168
  updateCheckInProgress = false,
164
169
  updateNotice = null,
165
170
  }: {
166
171
  updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
172
+ updateProgress?: ReturnType<typeof createInitialState>["updateProgress"];
167
173
  updateCheckInProgress?: boolean;
168
174
  updateNotice?: string | null;
169
175
  }) {
170
176
  const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
171
177
  initialState.updateAvailable = updateAvailable;
178
+ initialState.updateProgress = updateProgress;
172
179
  initialState.updateCheckInProgress = updateCheckInProgress;
173
180
  initialState.updateNotice = updateNotice;
174
181
  const [state, dispatch] = useReducer(appReducer, initialState);
@@ -258,7 +265,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
258
265
  }
259
266
 
260
267
  describe("Header", () => {
261
- test("shows the self-update shortcut for standalone binaries", async () => {
268
+ test("shows automatic self-update status for standalone binaries", async () => {
262
269
  testSetup = await testRender(
263
270
  <HeaderHarness updateAvailable={{
264
271
  version: "0.3.0",
@@ -274,7 +281,8 @@ describe("Header", () => {
274
281
  const frame = testSetup.captureCharFrame();
275
282
 
276
283
  expect(frame).toContain("v0.3.0 available");
277
- expect(frame).toContain("press u to update");
284
+ expect(frame).toContain("starting download");
285
+ expect(frame).not.toContain("press u to update");
278
286
  });
279
287
 
280
288
  test("shows the manual npm command when self-update is disabled", async () => {
@@ -297,7 +305,26 @@ describe("Header", () => {
297
305
  expect(frame).not.toContain("press u to update");
298
306
  });
299
307
 
300
- test("shows update check progress and notices", async () => {
308
+ test("shows update download progress and notices", async () => {
309
+ testSetup = await testRender(
310
+ <HeaderHarness
311
+ updateAvailable={{
312
+ version: "0.3.0",
313
+ tagName: "v0.3.0",
314
+ downloadUrl: "https://example.com/gloomberb",
315
+ publishedAt: "2026-04-01T00:00:00.000Z",
316
+ updateAction: { kind: "self" },
317
+ }}
318
+ updateProgress={{ phase: "downloading", percent: 42 }}
319
+ />,
320
+ { width: 120, height: 2 },
321
+ );
322
+
323
+ await testSetup.renderOnce();
324
+ expect(testSetup.captureCharFrame()).toContain("Downloading v0.3.0: 42%");
325
+
326
+ testSetup.renderer.destroy();
327
+
301
328
  testSetup = await testRender(
302
329
  <HeaderHarness updateCheckInProgress />,
303
330
  { width: 120, height: 2 },
@@ -648,4 +675,49 @@ describe("Shell", () => {
648
675
  expect(updateLayout?.layout.floating).toEqual([]);
649
676
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
650
677
  });
678
+
679
+ test("keeps the last floating pane body row visible above the border", async () => {
680
+ const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
681
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
682
+ if (!detailPane) throw new Error("missing detail pane");
683
+
684
+ const floatingOnlyLayout = {
685
+ dockRoot: null,
686
+ instances: [{ ...detailPane }],
687
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
688
+ };
689
+ const state = {
690
+ ...createInitialState({
691
+ ...config,
692
+ layout: cloneLayout(floatingOnlyLayout),
693
+ layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
694
+ }),
695
+ focusedPaneId: "ticker-detail:main",
696
+ };
697
+
698
+ testSetup = await testRender(
699
+ <AppContext value={{ state, dispatch: () => {} }}>
700
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
701
+ <Shell pluginRegistry={createShellPluginRegistry({
702
+ tickerDetailComponent: ({ width, height }) => (
703
+ <box flexDirection="column" width={width} height={height}>
704
+ <box flexGrow={1} />
705
+ <box paddingLeft={1}>
706
+ <text>Footer Probe</text>
707
+ </box>
708
+ </box>
709
+ ),
710
+ })} />
711
+ </DialogProvider>
712
+ </AppContext>,
713
+ { width: 40, height: 12 },
714
+ );
715
+
716
+ await act(async () => {
717
+ await testSetup!.renderOnce();
718
+ await testSetup!.renderOnce();
719
+ });
720
+
721
+ expect(testSetup.captureCharFrame()).toContain("Footer Probe");
722
+ });
651
723
  });
@@ -38,6 +38,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
38
38
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
39
39
  import { FloatingPaneWrapper } from "./floating-pane";
40
40
  import { PaneWrapper } from "./pane";
41
+ import { getPaneBodyHeight } from "./pane-sizing";
41
42
 
42
43
  interface ShellProps {
43
44
  pluginRegistry: PluginRegistry;
@@ -984,6 +985,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
984
985
  if (!pane) return null;
985
986
  const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
986
987
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
988
+ const bodyHeight = getPaneBodyHeight(leaf.rect.height);
987
989
  return (
988
990
  <box
989
991
  key={`dock:${leaf.instanceId}`}
@@ -1007,7 +1009,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1007
1009
  paneType={pane.instance.paneId}
1008
1010
  focused={focused}
1009
1011
  width={leaf.rect.width}
1010
- height={leaf.rect.height - 1}
1012
+ height={bodyHeight}
1011
1013
  />
1012
1014
  </PaneInstanceProvider>
1013
1015
  </PaneWrapper>
@@ -1019,6 +1021,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1019
1021
  const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1020
1022
  const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1021
1023
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1024
+ const bodyHeight = getPaneBodyHeight(preview.height);
1022
1025
  return (
1023
1026
  <FloatingPaneWrapper
1024
1027
  key={`float:${pane.instance.instanceId}`}
@@ -1038,7 +1041,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1038
1041
  paneType={pane.instance.paneId}
1039
1042
  focused={focused}
1040
1043
  width={preview.width}
1041
- height={preview.height - 1}
1044
+ height={bodyHeight}
1042
1045
  close={() => handleFloatingClose(pane.instance.instanceId)}
1043
1046
  />
1044
1047
  </PaneInstanceProvider>
@@ -86,7 +86,13 @@ const TickerListHeader = memo(function TickerListHeader({
86
86
  scrollX
87
87
  focusable={false}
88
88
  >
89
- <box flexDirection="row" height={1} paddingX={1}>
89
+ <box
90
+ flexDirection="row"
91
+ height={1}
92
+ width="100%"
93
+ paddingX={1}
94
+ backgroundColor={colors.panel}
95
+ >
90
96
  {columns.map((column) => {
91
97
  const isSorted = sortColumnId === column.id;
92
98
  const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
@@ -95,6 +101,7 @@ const TickerListHeader = memo(function TickerListHeader({
95
101
  <box
96
102
  key={column.id}
97
103
  width={column.width + 1}
104
+ backgroundColor={colors.panel}
98
105
  onMouseDown={onHeaderClick ? (event) => {
99
106
  event.preventDefault();
100
107
  onHeaderClick(column.id);
@@ -1,7 +1,8 @@
1
- import { useMemo, useState, type RefObject } from "react";
1
+ import { useCallback, useMemo, useState, type RefObject } from "react";
2
2
  import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { colors, hoverBg } from "../../theme/colors";
4
4
  import type { ColumnConfig } from "../../types/config";
5
+ import { useAppDispatch, usePaneInstance } from "../../state/app-context";
5
6
  import { padTo } from "../../utils/format";
6
7
  import { useDoubleClickActivation } from "../use-double-click-activation";
7
8
  import { EmptyState } from "./status";
@@ -75,6 +76,8 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
75
76
  virtualize = false,
76
77
  overscan = 3,
77
78
  }: DataTableProps<T, C>) {
79
+ const dispatch = useAppDispatch();
80
+ const paneInstanceId = usePaneInstance()?.instanceId ?? null;
78
81
  const [scrollVersion, setScrollVersion] = useState(0);
79
82
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
80
83
  const viewportHeight = virtualize
@@ -106,11 +109,21 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
106
109
  }
107
110
  onBodyScrollActivity();
108
111
  };
112
+ const focusPane = useCallback(() => {
113
+ if (!paneInstanceId) return;
114
+ dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
115
+ }, [dispatch, paneInstanceId]);
109
116
 
110
117
  return (
111
118
  <>
112
119
  <scrollbox ref={headerScrollRef} height={1} scrollX focusable={false}>
113
- <box flexDirection="row" height={1} paddingX={1}>
120
+ <box
121
+ flexDirection="row"
122
+ height={1}
123
+ width="100%"
124
+ paddingX={1}
125
+ backgroundColor={colors.panel}
126
+ >
114
127
  {columns.map((column) => {
115
128
  const isSorted = sortColumnId === column.id;
116
129
  const indicator = isSorted
@@ -127,7 +140,9 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
127
140
  <box
128
141
  key={column.id}
129
142
  width={column.width + 1}
143
+ backgroundColor={colors.panel}
130
144
  onMouseDown={(event) => {
145
+ focusPane();
131
146
  event.preventDefault();
132
147
  onHeaderClick(column.id);
133
148
  }}
@@ -150,7 +165,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
150
165
  scrollX
151
166
  scrollY
152
167
  focusable={false}
153
- onMouseDown={() => queueMicrotask(syncHeaderScroll)}
168
+ onMouseDown={() => {
169
+ focusPane();
170
+ queueMicrotask(syncHeaderScroll);
171
+ }}
154
172
  onMouseUp={() => queueMicrotask(handleBodyScrollActivity)}
155
173
  onMouseDrag={() => queueMicrotask(handleBodyScrollActivity)}
156
174
  onMouseScroll={() => queueMicrotask(handleBodyScrollActivity)}
@@ -181,6 +199,7 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
181
199
  backgroundColor={rowBg}
182
200
  onMouseMove={() => setHoveredIdx(index)}
183
201
  onMouseDown={(event) => {
202
+ focusPane();
184
203
  event.preventDefault();
185
204
  handleRowMouseDown(getItemKey(item, index), {
186
205
  item,
@@ -198,6 +217,7 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
198
217
  key={column.id}
199
218
  width={column.width + 1}
200
219
  onMouseDown={(event) => {
220
+ focusPane();
201
221
  if (cell.onMouseDown) {
202
222
  cell.onMouseDown(event);
203
223
  return;
@@ -84,6 +84,7 @@ class MemoryPersistence implements PluginPersistence {
84
84
  }
85
85
 
86
86
  afterEach(() => {
87
+ apiClient.dispose();
87
88
  apiClient.setSessionToken(null);
88
89
  apiClient.connectChannel = originalConnectChannel;
89
90
  apiClient.getSession = originalGetSession;
@@ -203,6 +204,35 @@ describe("ChatController", () => {
203
204
  controller.reset(true);
204
205
  });
205
206
 
207
+ test("dispose stops verification polling and closes the live connection", () => {
208
+ const controller = new ChatController();
209
+ let closed = false;
210
+
211
+ apiClient.setSessionToken("token-123");
212
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
213
+ (controller as any).syncVerificationPolling();
214
+ expect((controller as any).verificationPollTimer).not.toBeNull();
215
+
216
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: true };
217
+ apiClient.connectChannel = () => ({
218
+ send: async () => {
219
+ throw new Error("not implemented");
220
+ },
221
+ close: () => {
222
+ closed = true;
223
+ },
224
+ });
225
+
226
+ controller.ensureConnection();
227
+ expect((controller as any).wsConnection).not.toBeNull();
228
+
229
+ controller.dispose();
230
+
231
+ expect(closed).toBe(true);
232
+ expect((controller as any).verificationPollTimer).toBeNull();
233
+ expect((controller as any).wsConnection).toBeNull();
234
+ });
235
+
206
236
  test("keeps the cached session when session refresh fails transiently", async () => {
207
237
  const persistence = new MemoryPersistence();
208
238
  const controller = new ChatController();
@@ -1,5 +1,6 @@
1
1
  import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
2
2
  import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
3
+ import { debugLog } from "../../utils/debug-log";
3
4
  import { toTimestampMillis } from "../../utils/timestamp";
4
5
 
5
6
  const SESSION_STATE_KEY = "session";
@@ -18,6 +19,7 @@ const VERIFICATION_POLL_MS = 5_000;
18
19
  const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
19
20
  const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
20
21
  const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
22
+ const chatLog = debugLog.createLogger("chat-controller");
21
23
 
22
24
  interface PersistedSessionState {
23
25
  sessionToken: string | null;
@@ -326,6 +328,22 @@ export class ChatController {
326
328
  this.emit();
327
329
  }
328
330
 
331
+ dispose(): void {
332
+ chatLog.info("dispose controller", {
333
+ listeners: this.listeners.size,
334
+ hasConnection: !!this.wsConnection,
335
+ });
336
+ this.stopVerificationPolling();
337
+ this.wsConnection?.close();
338
+ this.wsConnection = null;
339
+ this.wsConnected = false;
340
+ this.messagesLoading = false;
341
+ this.refreshMessagesPromise = null;
342
+ this.openViewCount = 0;
343
+ this.toastFn = () => {};
344
+ this.listeners.clear();
345
+ }
346
+
329
347
  send(content: string, replyToId?: string): void {
330
348
  if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
331
349
  if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
@@ -674,6 +674,11 @@ export const gloomberbCloudPlugin: GloomPlugin = {
674
674
  hidden: () => !apiClient.getSessionToken(),
675
675
  });
676
676
  },
677
+
678
+ dispose() {
679
+ chatController.dispose();
680
+ apiClient.dispose();
681
+ },
677
682
  };
678
683
 
679
684
  export const chatPlugin = gloomberbCloudPlugin;
@@ -25,6 +25,11 @@ export function setLayoutManagerDispatch(
25
25
  getStateRef = getState;
26
26
  }
27
27
 
28
+ export function clearLayoutManagerDispatch() {
29
+ dispatchRef = null;
30
+ getStateRef = null;
31
+ }
32
+
28
33
  function persistLayout(layout: LayoutConfig) {
29
34
  if (!dispatchRef) return;
30
35
  dispatchRef({ type: "PUSH_LAYOUT_HISTORY" });
@@ -261,4 +266,8 @@ export const layoutManagerPlugin: GloomPlugin = {
261
266
  },
262
267
  });
263
268
  },
269
+
270
+ dispose() {
271
+ clearLayoutManagerDispatch();
272
+ },
264
273
  };
@@ -5,6 +5,7 @@ import {
5
5
  applyTickByTickAllLastToQuote,
6
6
  applyTickByTickBidAskToQuote,
7
7
  diagnoseLocalIbkrPortIssue,
8
+ IbkrGatewayServiceManager,
8
9
  IbkrGatewayService,
9
10
  parseIbkrHistoricalBarTime,
10
11
  resolveGatewayConnection,
@@ -521,4 +522,25 @@ describe("IbkrGatewayService", () => {
521
522
  expect(service.getSnapshot().openOrders).toEqual([]);
522
523
  expect(service.getSnapshot().executions).toEqual([]);
523
524
  });
525
+
526
+ test("destroyAll disconnects every cached gateway service", async () => {
527
+ const manager = new IbkrGatewayServiceManager();
528
+ let disconnectCount = 0;
529
+
530
+ (manager as any).services.set("one", {
531
+ disconnect: async () => {
532
+ disconnectCount += 1;
533
+ },
534
+ });
535
+ (manager as any).services.set("two", {
536
+ disconnect: async () => {
537
+ disconnectCount += 1;
538
+ },
539
+ });
540
+
541
+ await manager.destroyAll();
542
+
543
+ expect(disconnectCount).toBe(2);
544
+ expect((manager as any).services.size).toBe(0);
545
+ });
524
546
  });
@@ -2393,6 +2393,12 @@ export class IbkrGatewayServiceManager {
2393
2393
  await service.disconnect();
2394
2394
  this.services.delete(instanceId);
2395
2395
  }
2396
+
2397
+ async destroyAll(): Promise<void> {
2398
+ const services = [...this.services.values()];
2399
+ this.services.clear();
2400
+ await Promise.allSettled(services.map((service) => service.disconnect()));
2401
+ }
2396
2402
  }
2397
2403
 
2398
2404
  export const ibkrGatewayManager = new IbkrGatewayServiceManager();
@@ -1,7 +1,7 @@
1
1
  import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
2
2
  import { ibkrBroker } from "./broker-adapter";
3
3
  import { buildPersistedIbkrGatewayConfig } from "./config";
4
- import { setResolvedIbkrGatewayListener } from "./gateway-service";
4
+ import { ibkrGatewayManager, setResolvedIbkrGatewayListener } from "./gateway-service";
5
5
  import {
6
6
  getTradeTicketState,
7
7
  getTradingPaneState,
@@ -150,4 +150,10 @@ export const ibkrPlugin: GloomPlugin = {
150
150
  },
151
151
  });
152
152
  },
153
+
154
+ dispose() {
155
+ lastSelectedTickerSymbol = null;
156
+ setResolvedIbkrGatewayListener(null);
157
+ void ibkrGatewayManager.destroyAll().catch(() => {});
158
+ },
153
159
  };
@@ -26,6 +26,7 @@ import { subscribePolymarketMarket } from "./services/polymarket-ws";
26
26
  import type {
27
27
  PredictionBrowseTab,
28
28
  PredictionCategoryId,
29
+ PredictionVenue,
29
30
  PredictionListRow,
30
31
  PredictionMarketDetail,
31
32
  PredictionMarketSummary,
@@ -36,6 +37,48 @@ import type {
36
37
 
37
38
  const KEYBOARD_DETAIL_LOAD_DELAY_MS = 140;
38
39
 
40
+ function formatPredictionVenueLabel(venue: PredictionVenue): string {
41
+ return venue === "polymarket" ? "Polymarket" : "Kalshi";
42
+ }
43
+
44
+ function joinPredictionVenueLabels(venues: PredictionVenue[]): string {
45
+ const labels = [...new Set(venues.map(formatPredictionVenueLabel))];
46
+ if (labels.length <= 1) return labels[0] ?? "";
47
+ return `${labels.slice(0, -1).join(" and ")} and ${labels.at(-1)}`;
48
+ }
49
+
50
+ function formatPredictionLoadError(
51
+ venue: PredictionVenue,
52
+ subject: "markets" | "market detail",
53
+ error: unknown,
54
+ ): string {
55
+ const venueLabel = formatPredictionVenueLabel(venue);
56
+ const fallback = `Could not load ${venueLabel} ${subject}.`;
57
+ if (!(error instanceof Error)) {
58
+ return fallback;
59
+ }
60
+
61
+ const message = error.message.trim();
62
+ if (message.length === 0) {
63
+ return fallback;
64
+ }
65
+
66
+ const requestFailureMatch = message.match(/Request failed \((\d+)\)/i);
67
+ if (requestFailureMatch) {
68
+ return `${venueLabel} ${subject} request failed (${requestFailureMatch[1]}).`;
69
+ }
70
+
71
+ if (
72
+ /typo in the url or port|access the url|unable to connect|could not resolve host|connection refused/i.test(
73
+ message,
74
+ )
75
+ ) {
76
+ return `${venueLabel} is unavailable right now.`;
77
+ }
78
+
79
+ return `${fallback} ${message}`;
80
+ }
81
+
39
82
  export function usePredictionMarketsDataState({
40
83
  browseTab,
41
84
  categoryId,
@@ -152,14 +195,84 @@ export function usePredictionMarketsDataState({
152
195
  ].filter((value): value is string => value != null),
153
196
  [includeKalshi, includePolymarket, kalshiCatalogKey, polymarketCatalogKey],
154
197
  );
198
+ const activeCatalogSources = useMemo(
199
+ () =>
200
+ [
201
+ includePolymarket
202
+ ? {
203
+ venue: "polymarket" as const,
204
+ cacheKey: polymarketCatalogKey,
205
+ error: catalogErrors[polymarketCatalogKey] ?? null,
206
+ markets: polymarketCatalog,
207
+ }
208
+ : null,
209
+ includeKalshi
210
+ ? {
211
+ venue: "kalshi" as const,
212
+ cacheKey: kalshiCatalogKey,
213
+ error: catalogErrors[kalshiCatalogKey] ?? null,
214
+ markets: kalshiCatalog,
215
+ }
216
+ : null,
217
+ ].filter(
218
+ (
219
+ value,
220
+ ): value is {
221
+ venue: PredictionVenue;
222
+ cacheKey: string;
223
+ error: string | null;
224
+ markets: PredictionMarketSummary[];
225
+ } => value != null,
226
+ ),
227
+ [
228
+ catalogErrors,
229
+ includeKalshi,
230
+ includePolymarket,
231
+ kalshiCatalog,
232
+ kalshiCatalogKey,
233
+ polymarketCatalog,
234
+ polymarketCatalogKey,
235
+ ],
236
+ );
155
237
  const catalogLoadCount = activeCatalogKeys.reduce(
156
238
  (count, cacheKey) => count + (catalogPending[cacheKey] ?? 0),
157
239
  0,
158
240
  );
159
- const catalogError = activeCatalogKeys
160
- .map((cacheKey) => catalogErrors[cacheKey])
161
- .filter((value): value is string => !!value)
162
- .join(" ") || null;
241
+ const catalogStatus = useMemo(() => {
242
+ const failingSources = activeCatalogSources.filter((source) => !!source.error);
243
+ if (failingSources.length === 0) {
244
+ return null;
245
+ }
246
+
247
+ const loadedSources = activeCatalogSources.filter(
248
+ (source) => !source.error && source.markets.length > 0,
249
+ );
250
+ if (failingSources.length < activeCatalogSources.length) {
251
+ const unavailableVenues = joinPredictionVenueLabels(
252
+ failingSources.map((source) => source.venue),
253
+ );
254
+ if (loadedSources.length > 0) {
255
+ return {
256
+ tone: "warning" as const,
257
+ message: `${unavailableVenues} unavailable right now; showing ${joinPredictionVenueLabels(
258
+ loadedSources.map((source) => source.venue),
259
+ )} markets.`,
260
+ };
261
+ }
262
+ return {
263
+ tone: "warning" as const,
264
+ message: `${unavailableVenues} unavailable right now.`,
265
+ };
266
+ }
267
+
268
+ return {
269
+ tone: "danger" as const,
270
+ message: failingSources
271
+ .map((source) => source.error)
272
+ .filter((value): value is string => !!value)
273
+ .join(" "),
274
+ };
275
+ }, [activeCatalogSources]);
163
276
 
164
277
  const allMarkets = useMemo(() => {
165
278
  const merged: PredictionMarketSummary[] = [];
@@ -276,9 +389,7 @@ export function usePredictionMarketsDataState({
276
389
  updatePredictionErrorState(
277
390
  current,
278
391
  cacheKey,
279
- error instanceof Error
280
- ? error.message
281
- : "Could not load Polymarket markets.",
392
+ formatPredictionLoadError("polymarket", "markets", error),
282
393
  ),
283
394
  );
284
395
  } finally {
@@ -310,9 +421,7 @@ export function usePredictionMarketsDataState({
310
421
  updatePredictionErrorState(
311
422
  current,
312
423
  cacheKey,
313
- error instanceof Error
314
- ? error.message
315
- : "Could not load Kalshi markets.",
424
+ formatPredictionLoadError("kalshi", "markets", error),
316
425
  ),
317
426
  );
318
427
  } finally {
@@ -360,9 +469,7 @@ export function usePredictionMarketsDataState({
360
469
  updatePredictionErrorState(
361
470
  current,
362
471
  cacheKey,
363
- error instanceof Error
364
- ? error.message
365
- : "Could not load market detail.",
472
+ formatPredictionLoadError(summary.venue, "market detail", error),
366
473
  ),
367
474
  );
368
475
  if (currentDetailCacheKeyRef.current === cacheKey) {
@@ -605,7 +712,7 @@ export function usePredictionMarketsDataState({
605
712
  }, []);
606
713
 
607
714
  return {
608
- catalogError,
715
+ catalogStatus,
609
716
  catalogLoadCount,
610
717
  debouncedSearchQuery,
611
718
  detail,
@@ -692,7 +692,7 @@ export function usePredictionMarketsController({
692
692
  paneSettings,
693
693
  browseTab,
694
694
  categoryId,
695
- catalogError: data.catalogError,
695
+ catalogStatus: data.catalogStatus,
696
696
  catalogLoadCount: data.catalogLoadCount,
697
697
  detail: data.detail,
698
698
  detailError: data.detailError,
@@ -1,7 +1,7 @@
1
1
  import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
2
2
  import { parsePredictionSearchShortcut } from "./navigation";
3
3
  import { PredictionMarketsPane } from "./pane";
4
- import { attachPredictionMarketsPersistence } from "./services/fetch";
4
+ import { attachPredictionMarketsPersistence, resetPredictionMarketsPersistence } from "./services/fetch";
5
5
  import { predictionMarketsCliCommand } from "./cli";
6
6
  import {
7
7
  buildPredictionMarketsPaneSettingsDef,
@@ -101,4 +101,8 @@ export const predictionMarketsPlugin: GloomPlugin = {
101
101
  },
102
102
  });
103
103
  },
104
+
105
+ dispose() {
106
+ resetPredictionMarketsPersistence();
107
+ },
104
108
  };
@@ -83,6 +83,57 @@ describe("prediction markets pane interactions", () => {
83
83
  expect(frame).toContain("Will the Fed cut rates?");
84
84
  });
85
85
 
86
+ test("shows a venue warning instead of a raw fetch error when one catalog fails", async () => {
87
+ attachPredictionMarketsPersistence(new MemoryPersistence());
88
+
89
+ globalThis.fetch = (async (input: Request | string | URL) => {
90
+ const url = String(input);
91
+ if (url.includes("gamma-api.polymarket.com")) {
92
+ throw new Error("Unable to connect. Was there a typo in the url or port?");
93
+ }
94
+ if (url.includes("/trade-api/v2/events?")) {
95
+ return new Response(
96
+ JSON.stringify({
97
+ events: [
98
+ {
99
+ title: "Fed series",
100
+ category: "Economics",
101
+ event_ticker: "FED-1",
102
+ series_ticker: "FED",
103
+ markets: [
104
+ {
105
+ ticker: "KAL-1",
106
+ title: "Will the Fed cut rates?",
107
+ yes_sub_title: "Yes",
108
+ event_ticker: "FED-1",
109
+ status: "open",
110
+ market_type: "binary",
111
+ last_price_dollars: "0.48",
112
+ volume_24h_fp: "15000",
113
+ volume_fp: "90000",
114
+ open_interest_fp: "45000",
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ }),
120
+ { status: 200 },
121
+ );
122
+ }
123
+ return new Response(JSON.stringify({}), { status: 200 });
124
+ }) as unknown as typeof fetch;
125
+
126
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
127
+ await flushFrames(testSetup);
128
+
129
+ const frame = testSetup.captureCharFrame();
130
+ expect(frame).toContain("Will the Fed cut rates?");
131
+ expect(frame).toContain(
132
+ "Polymarket unavailable right now; showing Kalshi markets.",
133
+ );
134
+ expect(frame).not.toContain("Was there a typo in the url or port?");
135
+ });
136
+
86
137
  test("selects a market on single click and opens detail on double click", async () => {
87
138
  installPredictionMarketMocks();
88
139
 
@@ -127,6 +178,33 @@ describe("prediction markets pane interactions", () => {
127
178
  expect(frame).toContain("Kalshi primary rule");
128
179
  });
129
180
 
181
+ test("focuses the pane when a market row is clicked", async () => {
182
+ installPredictionMarketMocks();
183
+
184
+ testSetup = await testRender(
185
+ <Harness initialFocusedPaneId="portfolio-list:main" />,
186
+ { width: 120, height: 34 },
187
+ );
188
+ await flushFrames(testSetup);
189
+
190
+ expect(harnessStateRef.current?.focusedPaneId).toBe("portfolio-list:main");
191
+
192
+ const frame = testSetup.captureCharFrame();
193
+ const lines = frame.split("\n");
194
+ const kalshiRow = lines.findIndex((line) =>
195
+ line.includes("Will the Fed cut rates?"),
196
+ );
197
+ const kalshiCol = lines[kalshiRow]?.indexOf("Will the Fed cut rates?") ?? -1;
198
+
199
+ await act(async () => {
200
+ await testSetup!.mockMouse.click(kalshiCol + 1, kalshiRow);
201
+ await testSetup!.renderOnce();
202
+ });
203
+ await flushFrames(testSetup);
204
+
205
+ expect(harnessStateRef.current?.focusedPaneId).toBe(TEST_PANE_ID);
206
+ });
207
+
130
208
  test("loads selected detail once instead of refetching in a render loop", async () => {
131
209
  const { fetchUrls } = installPredictionMarketMocks();
132
210
 
@@ -11,6 +11,10 @@ import type { PredictionBrowseTab, PredictionCategoryId } from "./types";
11
11
 
12
12
  export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
13
13
  const controller = usePredictionMarketsController({ focused });
14
+ const catalogStatusColor =
15
+ controller.catalogStatus?.tone === "danger"
16
+ ? colors.negative
17
+ : colors.borderFocused;
14
18
 
15
19
  const browseContent = (
16
20
  <box
@@ -113,9 +117,9 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
113
117
  </scrollbox>
114
118
  ) : null}
115
119
 
116
- {controller.catalogError ? (
117
- <box height={1} paddingX={1}>
118
- <text fg={colors.negative}>{controller.catalogError}</text>
120
+ {controller.catalogStatus ? (
121
+ <box height={1} paddingX={1} width={width} backgroundColor={colors.panel}>
122
+ <text fg={catalogStatusColor}>{controller.catalogStatus.message}</text>
119
123
  </box>
120
124
  ) : null}
121
125
 
@@ -19,6 +19,10 @@ export function attachPredictionMarketsPersistence(
19
19
  predictionMarketsPersistence = persistence;
20
20
  }
21
21
 
22
+ export function resetPredictionMarketsPersistence(): void {
23
+ predictionMarketsPersistence = null;
24
+ }
25
+
22
26
  export function getPredictionMarketsPersistence(): PluginPersistence | null {
23
27
  return predictionMarketsPersistence;
24
28
  }
@@ -99,11 +99,22 @@ export class MemoryPersistence implements PluginPersistence {
99
99
 
100
100
  attachPredictionMarketsPersistence(new MemoryPersistence());
101
101
 
102
- export function createConfig(): AppConfig {
102
+ export function createConfig(options?: {
103
+ initialFocusedPaneId?: string;
104
+ }): AppConfig {
103
105
  const config = createDefaultConfig("/tmp/gloomberb-prediction-markets");
106
+ const initialFocusedPaneId = options?.initialFocusedPaneId ?? TEST_PANE_ID;
107
+ const extraInstances = initialFocusedPaneId !== TEST_PANE_ID
108
+ ? [{
109
+ instanceId: initialFocusedPaneId,
110
+ paneId: initialFocusedPaneId.split(":")[0] ?? "portfolio-list",
111
+ binding: { kind: "none" as const },
112
+ }]
113
+ : [];
104
114
  const layout = {
105
115
  dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
106
116
  instances: [
117
+ ...extraInstances,
107
118
  {
108
119
  instanceId: TEST_PANE_ID,
109
120
  paneId: "prediction-markets",
@@ -424,13 +435,17 @@ export function installPredictionMarketMocks() {
424
435
  return { fetchUrls };
425
436
  }
426
437
 
427
- export function Harness() {
438
+ export function Harness({
439
+ initialFocusedPaneId = TEST_PANE_ID,
440
+ }: {
441
+ initialFocusedPaneId?: string;
442
+ } = {}) {
428
443
  const runtime = useMemo(() => createRuntime(), []);
429
444
  const [state, dispatch] = useReducer(
430
445
  appReducer,
431
446
  (() => {
432
- const initial = createInitialState(createConfig());
433
- initial.focusedPaneId = TEST_PANE_ID;
447
+ const initial = createInitialState(createConfig({ initialFocusedPaneId }));
448
+ initial.focusedPaneId = initialFocusedPaneId;
434
449
  return initial;
435
450
  })(),
436
451
  );
@@ -442,7 +457,7 @@ export function Harness() {
442
457
  <PredictionMarketsPane
443
458
  paneId={TEST_PANE_ID}
444
459
  paneType="prediction-markets"
445
- focused
460
+ focused={state.focusedPaneId === TEST_PANE_ID}
446
461
  width={120}
447
462
  height={34}
448
463
  />
@@ -630,4 +630,27 @@ export class PluginRegistry implements PluginRuntimeAccess {
630
630
  this.plugins.delete(pluginId);
631
631
  this.events.emit("plugin:unregistered", { pluginId });
632
632
  }
633
+
634
+ destroy(): void {
635
+ for (const pluginId of [...this.plugins.keys()].reverse()) {
636
+ try {
637
+ this.unregister(pluginId);
638
+ } catch (error) {
639
+ this.registryLog.error("Failed to unregister plugin during registry destroy", {
640
+ pluginId,
641
+ error: error instanceof Error ? error.message : String(error),
642
+ });
643
+ }
644
+ }
645
+ this.pluginResumeListeners.clear();
646
+ if (sharedRegistry === this) {
647
+ sharedRegistry = undefined;
648
+ }
649
+ if (sharedDataProvider === this.dataProvider) {
650
+ sharedDataProvider = undefined;
651
+ }
652
+ if ((globalThis as any).__gloomRegistry === this) {
653
+ delete (globalThis as any).__gloomRegistry;
654
+ }
655
+ }
633
656
  }
@@ -272,6 +272,8 @@ describe("performUpdate", () => {
272
272
  });
273
273
 
274
274
  expect(readFileSync(execPath)).toEqual(nextBinary);
275
+ expect(progress[0]).toEqual({ phase: "downloading", percent: 0 });
276
+ expect(progress).toContainEqual({ phase: "downloading", percent: 100 });
275
277
  expect(progress.at(-1)).toEqual({ phase: "done" });
276
278
  } finally {
277
279
  Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
@@ -647,6 +647,18 @@ class GloomApiClient {
647
647
  };
648
648
  }
649
649
 
650
+ dispose(): void {
651
+ cloudApiLog.info("dispose api client", {
652
+ quoteTargets: this.quoteTargets.size,
653
+ channelTargets: this.channelListeners.size,
654
+ });
655
+ this.channelListeners.clear();
656
+ this.quoteListeners.clear();
657
+ this.quoteTargets.clear();
658
+ this.reconnectDelayMs = 1000;
659
+ this.teardownSocket();
660
+ }
661
+
650
662
  async searchInstruments(query: string, limit = 10): Promise<InstrumentSearchResult[]> {
651
663
  const params = new URLSearchParams({
652
664
  q: query,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.4.0";
1
+ export const VERSION = "0.4.1";