gloomberb 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +2 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +62 -77
  14. package/src/components/chart/native/kitty.test.ts +23 -0
  15. package/src/components/chart/native/surface-visibility.ts +48 -0
  16. package/src/components/chart/static-chart-surface.tsx +28 -4
  17. package/src/components/chart/stock-chart.test.ts +0 -16
  18. package/src/components/chart/stock-chart.tsx +52 -71
  19. package/src/components/chart/time-axis-label.tsx +43 -0
  20. package/src/components/command-bar/command-bar.test.tsx +71 -8
  21. package/src/components/command-bar/command-bar.tsx +388 -713
  22. package/src/components/command-bar/confirm-body.tsx +51 -0
  23. package/src/components/command-bar/list-model.ts +77 -0
  24. package/src/components/command-bar/view-model.ts +0 -19
  25. package/src/components/command-bar/workflow-ops.ts +4 -0
  26. package/src/components/data-table-stack-view.test.tsx +39 -4
  27. package/src/components/data-table-stack-view.tsx +11 -104
  28. package/src/components/data-table-view.tsx +43 -79
  29. package/src/components/feed-data-table-stack-view.tsx +10 -17
  30. package/src/components/feed-stack-controller.ts +39 -0
  31. package/src/components/index.ts +6 -2
  32. package/src/components/layout/floating-pane.tsx +30 -2
  33. package/src/components/layout/header.tsx +10 -7
  34. package/src/components/layout/pane-content.tsx +2 -0
  35. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  36. package/src/components/layout/pane-cycle-order.ts +32 -0
  37. package/src/components/layout/pane-footer.tsx +26 -4
  38. package/src/components/layout/pane-header.tsx +5 -5
  39. package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
  40. package/src/components/layout/shell.test.tsx +66 -22
  41. package/src/components/layout/shell.tsx +13 -6
  42. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  43. package/src/components/pane-settings-dialog.tsx +12 -4
  44. package/src/components/pane-template-wizard.test.tsx +3 -4
  45. package/src/components/speedometer-gauge.test.tsx +45 -0
  46. package/src/components/speedometer-gauge.tsx +558 -0
  47. package/src/components/table-view-shared.tsx +158 -0
  48. package/src/components/ticker-list-table-view.tsx +42 -72
  49. package/src/components/ui/confirm-dialog.tsx +66 -0
  50. package/src/components/ui/data-table.tsx +18 -10
  51. package/src/components/ui/index.ts +0 -1
  52. package/src/components/ui/page-stack-view.tsx +39 -10
  53. package/src/components/ui/remote-image.tsx +15 -40
  54. package/src/components/ui/table-layout.test.ts +16 -0
  55. package/src/components/ui/table-layout.ts +10 -3
  56. package/src/components/ui/ui.test.tsx +100 -20
  57. package/src/core/state/app-state.ts +6 -3
  58. package/src/market-data/coordinator.test.ts +1 -1
  59. package/src/market-data/coordinator.ts +0 -1
  60. package/src/news/aggregator.ts +0 -3
  61. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  62. package/src/plugins/builtin/ai/providers.ts +0 -17
  63. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  64. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  65. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  66. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  67. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  68. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  69. package/src/plugins/builtin/chat-controller.ts +75 -6
  70. package/src/plugins/builtin/chat.test.tsx +6 -6
  71. package/src/plugins/builtin/chat.tsx +29 -20
  72. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  73. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  74. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  75. package/src/plugins/builtin/correlation/index.tsx +1 -6
  76. package/src/plugins/builtin/debug.tsx +1 -3
  77. package/src/plugins/builtin/econ/index.tsx +1 -2
  78. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  79. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  80. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  82. package/src/plugins/builtin/help.test.tsx +2 -2
  83. package/src/plugins/builtin/help.tsx +2 -2
  84. package/src/plugins/builtin/holders/index.tsx +10 -15
  85. package/src/plugins/builtin/insider/index.tsx +22 -89
  86. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  87. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  88. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  89. package/src/plugins/builtin/news.tsx +4 -16
  90. package/src/plugins/builtin/notes.tsx +18 -55
  91. package/src/plugins/builtin/options.test.tsx +58 -2
  92. package/src/plugins/builtin/options.tsx +108 -49
  93. package/src/plugins/builtin/plugin-groups.ts +2 -0
  94. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  95. package/src/plugins/builtin/research/index.tsx +5 -21
  96. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  97. package/src/plugins/builtin/sec.tsx +13 -66
  98. package/src/plugins/builtin/sectors/index.tsx +1 -1
  99. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  100. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  102. package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
  103. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +69 -4
  104. package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
  105. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  106. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  107. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  108. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  109. package/src/plugins/ibkr/gateway-service.ts +1 -1
  110. package/src/plugins/ibkr/index.test.tsx +0 -2
  111. package/src/plugins/ibkr/trade-tab.test.tsx +56 -5
  112. package/src/plugins/ibkr/trade-tab.tsx +13 -11
  113. package/src/plugins/pane-manager.ts +0 -13
  114. package/src/plugins/plugin-runtime.test.tsx +6 -6
  115. package/src/plugins/plugin-runtime.tsx +13 -8
  116. package/src/plugins/prediction-markets/cli.ts +0 -1
  117. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  118. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  119. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  120. package/src/plugins/registry.ts +10 -39
  121. package/src/react/input.ts +11 -2
  122. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  123. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  124. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  125. package/src/renderers/electrobun/view/input-host.tsx +123 -12
  126. package/src/renderers/electrobun/view/key-event.ts +1 -1
  127. package/src/renderers/electrobun/view/styles.css +331 -0
  128. package/src/renderers/electrobun/view/ui-host.tsx +22 -3
  129. package/src/renderers/opentui/chart-surface.tsx +179 -0
  130. package/src/renderers/opentui/host.tsx +6 -0
  131. package/src/renderers/opentui/image-loader.test.ts +25 -0
  132. package/src/renderers/opentui/image-loader.ts +105 -0
  133. package/src/renderers/opentui/image-surface.tsx +266 -0
  134. package/src/renderers/opentui/input-host.tsx +45 -6
  135. package/src/renderers/opentui/start.tsx +48 -30
  136. package/src/renderers/opentui/test-utils.tsx +17 -0
  137. package/src/renderers/opentui/ui-host.tsx +7 -3
  138. package/src/sources/provider-router.ts +0 -4
  139. package/src/state/app-context.test.ts +45 -0
  140. package/src/state/app-context.tsx +4 -0
  141. package/src/state/pane-scroll-registry.tsx +254 -0
  142. package/src/state/use-inline-tickers.test.tsx +73 -0
  143. package/src/state/use-inline-tickers.ts +7 -1
  144. package/src/test-support/plugin-runtime.ts +3 -2
  145. package/src/theme/colors.ts +12 -3
  146. package/src/theme/themes.test.ts +23 -1
  147. package/src/theme/themes.ts +115 -0
  148. package/src/types/broker.ts +1 -1
  149. package/src/types/data-provider.ts +0 -1
  150. package/src/types/plugin.ts +0 -2
  151. package/src/ui/host.tsx +4 -0
  152. package/src/ui/index.tsx +9 -1
  153. package/src/utils/back-navigation.ts +24 -1
  154. package/src/version.ts +1 -1
  155. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  156. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,11 +1,10 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useEffect, useRef, useState } from "react";
3
- import { testRender } from "../../renderers/opentui/test-utils";
4
- import { DialogProvider } from "@opentui-ui/dialog/react";
3
+ import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
5
4
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
6
5
  import { Button } from "./button";
7
6
  import { ChoiceDialog } from "./choice-dialog";
8
- import { DataTable } from "./data-table";
7
+ import { DataTable, type DataTableColumn } from "./data-table";
9
8
  import { TextField } from "./fields";
10
9
  import { ListView } from "./list-view";
11
10
  import { MultiSelectDialogButton } from "./multi-select-dialog";
@@ -21,12 +20,13 @@ import { Tabs } from "./tabs";
21
20
  import { ToggleList } from "../toggle-list";
22
21
  import { AppContext, PaneInstanceProvider, createInitialState } from "../../state/app-context";
23
22
  import { createDefaultConfig } from "../../types/config";
23
+ import { DataTableView } from "../data-table-view";
24
24
 
25
25
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
26
26
  let setListSelection: ((index: number) => void) | null = null;
27
27
  let selectedTableRow: string | null = null;
28
28
  let activatedTableRow: string | null = null;
29
- let tableHorizontalScrollbarVisible: boolean | null = null;
29
+ let tableScrollBoxForTest: ScrollBoxRenderable | null = null;
30
30
  let closedTab: string | null = null;
31
31
  let addedTab = false;
32
32
  let resolvedChoice: string | null = null;
@@ -125,34 +125,47 @@ function DataTableSectionHarness() {
125
125
  );
126
126
  }
127
127
 
128
- function DataTableNoHorizontalScrollHarness() {
128
+ function DataTableHorizontalScrollHarness({
129
+ columns = [{ id: "name", label: "NAME", width: 12, align: "left" }],
130
+ containerWidth = 32,
131
+ containerHeight = 5,
132
+ rowCount = 1,
133
+ showHorizontalScrollbar,
134
+ }: {
135
+ columns?: DataTableColumn[];
136
+ containerWidth?: number;
137
+ containerHeight?: number;
138
+ rowCount?: number;
139
+ showHorizontalScrollbar?: boolean;
140
+ }) {
129
141
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
130
142
  const scrollRef = useRef<ScrollBoxRenderable>(null);
131
- const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
143
+ const items = Array.from({ length: rowCount }, (_, index) => ({
144
+ id: `row-${index}`,
145
+ name: index === 0 ? "Alpha" : `Alpha ${index + 1}`,
146
+ }));
132
147
 
133
148
  useEffect(() => {
134
- tableHorizontalScrollbarVisible = scrollRef.current?.horizontalScrollBar.visible ?? null;
149
+ tableScrollBoxForTest = scrollRef.current;
135
150
  });
136
151
 
137
152
  return (
138
- <DataTable
139
- columns={[{ id: "name", label: "NAME", width: 12, align: "left" }]}
140
- items={[{ id: "alpha", name: "Alpha" }]}
153
+ <DataTableView
154
+ rootWidth={containerWidth}
155
+ rootHeight={containerHeight}
156
+ columns={columns}
157
+ items={items}
141
158
  sortColumnId={null}
142
159
  sortDirection="asc"
143
160
  onHeaderClick={() => {}}
144
161
  headerScrollRef={headerScrollRef}
145
162
  scrollRef={scrollRef}
146
- syncHeaderScroll={() => {}}
147
- onBodyScrollActivity={() => {}}
148
- hoveredIdx={hoveredIdx}
149
- setHoveredIdx={setHoveredIdx}
150
163
  getItemKey={(row) => row.id}
151
164
  isSelected={() => false}
152
165
  onSelect={() => {}}
153
166
  renderCell={(row) => ({ text: row.name })}
154
167
  emptyStateTitle="No rows."
155
- showHorizontalScrollbar={false}
168
+ showHorizontalScrollbar={showHorizontalScrollbar}
156
169
  />
157
170
  );
158
171
  }
@@ -192,7 +205,7 @@ function MultiSelectDialogButtonHarness() {
192
205
  const [values, setValues] = useState(["sma"]);
193
206
 
194
207
  return (
195
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
208
+ <TestDialogProvider>
196
209
  <MultiSelectDialogButton
197
210
  label="IND"
198
211
  title="Chart Indicators"
@@ -204,7 +217,7 @@ function MultiSelectDialogButtonHarness() {
204
217
  { value: "ema", label: "EMA" },
205
218
  ]}
206
219
  />
207
- </DialogProvider>
220
+ </TestDialogProvider>
208
221
  );
209
222
  }
210
223
 
@@ -253,7 +266,7 @@ afterEach(() => {
253
266
  setListSelection = null;
254
267
  selectedTableRow = null;
255
268
  activatedTableRow = null;
256
- tableHorizontalScrollbarVisible = null;
269
+ tableScrollBoxForTest = null;
257
270
  closedTab = null;
258
271
  addedTab = false;
259
272
  resolvedChoice = null;
@@ -650,10 +663,77 @@ describe("shared UI kit", () => {
650
663
 
651
664
  test("hides data table horizontal scrolling when disabled", async () => {
652
665
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
666
+ tableScrollBoxForTest = null;
667
+ testSetup = await testRender(
668
+ <AppContext value={{ state, dispatch: () => {} }}>
669
+ <PaneInstanceProvider paneId="portfolio-list:main">
670
+ <DataTableHorizontalScrollHarness showHorizontalScrollbar={false} />
671
+ </PaneInstanceProvider>
672
+ </AppContext>,
673
+ { width: 32, height: 5 },
674
+ );
675
+
676
+ await act(async () => {
677
+ await testSetup!.renderOnce();
678
+ await testSetup!.renderOnce();
679
+ });
680
+
681
+ expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(false);
682
+ });
683
+
684
+ test("hides data table horizontal scrolling when content fits", async () => {
685
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
686
+ tableScrollBoxForTest = null;
687
+ testSetup = await testRender(
688
+ <AppContext value={{ state, dispatch: () => {} }}>
689
+ <PaneInstanceProvider paneId="portfolio-list:main">
690
+ <DataTableHorizontalScrollHarness />
691
+ </PaneInstanceProvider>
692
+ </AppContext>,
693
+ { width: 32, height: 5 },
694
+ );
695
+
696
+ await act(async () => {
697
+ await testSetup!.renderOnce();
698
+ await testSetup!.renderOnce();
699
+ });
700
+
701
+ expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(false);
702
+ });
703
+
704
+ test("shows data table horizontal scrolling when content overflows", async () => {
705
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
706
+ tableScrollBoxForTest = null;
707
+ testSetup = await testRender(
708
+ <AppContext value={{ state, dispatch: () => {} }}>
709
+ <PaneInstanceProvider paneId="portfolio-list:main">
710
+ <DataTableHorizontalScrollHarness
711
+ containerWidth={24}
712
+ columns={[
713
+ { id: "name", label: "NAME", width: 24, align: "left" },
714
+ { id: "price", label: "PRICE", width: 18, align: "right" },
715
+ ]}
716
+ />
717
+ </PaneInstanceProvider>
718
+ </AppContext>,
719
+ { width: 24, height: 5 },
720
+ );
721
+
722
+ await act(async () => {
723
+ await testSetup!.renderOnce();
724
+ await testSetup!.renderOnce();
725
+ });
726
+
727
+ expect(tableScrollBoxForTest?.horizontalScrollBar.visible).toBe(true);
728
+ });
729
+
730
+ test("shows data table vertical scrolling when rows overflow", async () => {
731
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
732
+ tableScrollBoxForTest = null;
653
733
  testSetup = await testRender(
654
734
  <AppContext value={{ state, dispatch: () => {} }}>
655
735
  <PaneInstanceProvider paneId="portfolio-list:main">
656
- <DataTableNoHorizontalScrollHarness />
736
+ <DataTableHorizontalScrollHarness rowCount={20} containerHeight={5} />
657
737
  </PaneInstanceProvider>
658
738
  </AppContext>,
659
739
  { width: 32, height: 5 },
@@ -664,7 +744,7 @@ describe("shared UI kit", () => {
664
744
  await testSetup!.renderOnce();
665
745
  });
666
746
 
667
- expect(tableHorizontalScrollbarVisible).toBe(false);
747
+ expect(tableScrollBoxForTest?.verticalScrollBar.visible).toBe(true);
668
748
  });
669
749
 
670
750
  test("virtualizes data table rows by default", async () => {
@@ -387,10 +387,12 @@ function removeHistoryIndex(layoutHistory: Record<number, LayoutHistoryEntry>, r
387
387
 
388
388
  /** If paneId is a floating pane, bump its zIndex to the top. */
389
389
  function bringFloatingToFront(layout: LayoutConfig, paneId: string): LayoutConfig {
390
- const entry = layout.floating.find((e) => e.instanceId === paneId);
390
+ const entryIndex = layout.floating.findIndex((e) => e.instanceId === paneId);
391
+ const entry = entryIndex >= 0 ? layout.floating[entryIndex] : undefined;
391
392
  if (!entry) return layout;
392
393
  const maxZ = layout.floating.reduce((max, e) => Math.max(max, e.zIndex ?? 50), 0);
393
- if ((entry.zIndex ?? 50) >= maxZ) return layout; // already on top
394
+ const topEqualIndex = layout.floating.findLastIndex((e) => (e.zIndex ?? 50) === maxZ);
395
+ if ((entry.zIndex ?? 50) === maxZ && entryIndex === topEqualIndex) return layout; // already on top
394
396
  return {
395
397
  ...layout,
396
398
  floating: layout.floating.map((e) =>
@@ -792,7 +794,8 @@ export function appReducer(state: AppState, action: AppAction): AppState {
792
794
 
793
795
  case "FOCUS_PREV": {
794
796
  if (action.paneOrder.length === 0) return state;
795
- const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : 0;
797
+ const focusedIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : -1;
798
+ const currentIndex = focusedIndex >= 0 ? focusedIndex : 0;
796
799
  const nextPaneId = action.paneOrder[(currentIndex - 1 + action.paneOrder.length) % action.paneOrder.length]!;
797
800
  return focusPaneState(state, nextPaneId);
798
801
  }
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import { MarketDataCoordinator } from "./coordinator";
3
3
  import type { DataProvider, QuoteSubscriptionTarget } from "../types/data-provider";
4
4
  import type { InstrumentSearchResult } from "../types/instrument";
5
- import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
5
+ import type { PricePoint, Quote, TickerFinancials } from "../types/financials";
6
6
  import { createTestDataProvider } from "../test-support/data-provider";
7
7
 
8
8
  function createProvider(overrides: Partial<DataProvider> = {}): DataProvider {
@@ -4,7 +4,6 @@ import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types
4
4
  import type { ChartRequest, InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest } from "./request-types";
5
5
  import { QueryStore } from "./query-store";
6
6
  import type { ProviderAttempt, ProviderReasonCode, QueryEntry } from "./result-types";
7
- import { createIdleEntry } from "./result-types";
8
7
  import {
9
8
  buildArticleSummaryKey,
10
9
  buildChartKey,
@@ -447,6 +447,3 @@ export class NewsService {
447
447
  .slice(0, count);
448
448
  }
449
449
  }
450
-
451
- export type NewsAggregatorOptions = NewsServiceOptions;
452
- export class NewsAggregator extends NewsService {}
@@ -1,5 +1,5 @@
1
1
  import { Box, ScrollBox, Text, useUiCapabilities } from "../../../ui";
2
- import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { useRef, useEffect, useState, useCallback, type SetStateAction } from "react";
3
3
  import { useShortcut } from "../../../react/input";
4
4
  import { TextAttributes } from "../../../ui";
5
5
  import { type ScrollBoxRenderable, type TextareaRenderable } from "../../../ui";
@@ -33,7 +33,22 @@ const chatHistories = new Map<string, ChatMessage[]>();
33
33
  export { __setDetectedProvidersForTests };
34
34
  export type { AiProvider };
35
35
 
36
+ function sameMessages(left: readonly ChatMessage[], right: readonly ChatMessage[]): boolean {
37
+ return left.length === right.length && left.every((message, index) => {
38
+ const other = right[index];
39
+ if (!other) return false;
40
+ return message.role === other.role
41
+ && message.content === other.content
42
+ && message.loading === other.loading;
43
+ });
44
+ }
45
+
46
+ function historyKeyFor(providerId: string, symbol: string): string {
47
+ return `conversation:${providerId}:${symbol}`;
48
+ }
49
+
36
50
  export function __setAskAiHistoryForTests(symbol: string, messages: ChatMessage[]): void {
51
+ chatHistories.set(historyKeyFor("claude", symbol), messages);
37
52
  chatHistories.set(symbol, messages);
38
53
  }
39
54
 
@@ -58,7 +73,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
58
73
  const [providers] = useState(() => detectProviders());
59
74
  const defaultProviderId = resolveDefaultAiProviderId(providers);
60
75
  const [providerId, setProviderId] = usePluginState<string>("providerId", defaultProviderId);
61
- const [messages, setMessages] = useState<ChatMessage[]>([]);
76
+ const [conversationMessagesByKey, setConversationMessagesByKey] = useState<Record<string, ChatMessage[]>>({});
62
77
  const [inputFocused, setInputFocused] = useState(false);
63
78
  const [inputValue, setInputValue] = useState("");
64
79
  const inputRef = useRef<TextareaRenderable>(null);
@@ -73,6 +88,18 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
73
88
  const conversationKey = tickerSymbol && currentProvider
74
89
  ? `conversation:${currentProvider.id}:${tickerSymbol}`
75
90
  : "__conversation:none__";
91
+ const historyKey = tickerSymbol && currentProvider ? historyKeyFor(currentProvider.id, tickerSymbol) : null;
92
+ const messages = conversationMessagesByKey[conversationKey] ?? [];
93
+ const setMessages = useCallback((nextValue: SetStateAction<ChatMessage[]>) => {
94
+ setConversationMessagesByKey((previous) => {
95
+ const previousMessages = previous[conversationKey] ?? [];
96
+ const nextMessages = typeof nextValue === "function"
97
+ ? (nextValue as (value: ChatMessage[]) => ChatMessage[])(previousMessages)
98
+ : nextValue;
99
+ if (sameMessages(previousMessages, nextMessages)) return previous;
100
+ return { ...previous, [conversationKey]: nextMessages };
101
+ });
102
+ }, [conversationKey]);
76
103
  const [persistedConversation, setPersistedConversation] = usePluginState<PersistedConversation | null>(
77
104
  conversationKey,
78
105
  null,
@@ -81,36 +108,39 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
81
108
 
82
109
  useEffect(() => {
83
110
  if (!tickerSymbol) {
84
- setMessages((previous) => (previous.length === 0 ? previous : []));
111
+ setConversationMessagesByKey((previous) => {
112
+ const previousMessages = previous[conversationKey] ?? [];
113
+ return previousMessages.length === 0 ? previous : { ...previous, [conversationKey]: [] };
114
+ });
85
115
  return;
86
116
  }
87
117
 
88
- if (persistedConversation && Date.now() - persistedConversation.updatedAt <= ASK_AI_RETENTION_MS) {
89
- chatHistories.set(tickerSymbol, persistedConversation.messages);
118
+ if (historyKey && persistedConversation && Date.now() - persistedConversation.updatedAt <= ASK_AI_RETENTION_MS) {
119
+ chatHistories.set(historyKey, persistedConversation.messages);
90
120
  }
91
121
 
92
- const nextMessages = chatHistories.get(tickerSymbol) ?? [];
93
- setMessages((previous) => (
94
- previous.length === nextMessages.length && previous.every((message, index) => message === nextMessages[index])
95
- ? previous
96
- : nextMessages
97
- ));
98
- }, [persistedConversation, tickerSymbol]);
122
+ const nextMessages = (historyKey ? chatHistories.get(historyKey) : null) ?? chatHistories.get(tickerSymbol) ?? [];
123
+ setConversationMessagesByKey((previous) => {
124
+ const previousMessages = previous[conversationKey] ?? [];
125
+ return sameMessages(previousMessages, nextMessages) ? previous : { ...previous, [conversationKey]: nextMessages };
126
+ });
127
+ }, [conversationKey, historyKey, persistedConversation, tickerSymbol]);
99
128
 
100
129
  useEffect(() => {
101
- if (!tickerSymbol || !currentProvider || messages.length === 0) return;
130
+ if (!tickerSymbol || !currentProvider || !historyKey || messages.length === 0) return;
102
131
  const hasLoading = messages.some((message) => message.loading);
103
132
  if (hasLoading) {
104
- chatHistories.set(tickerSymbol, messages);
133
+ chatHistories.set(historyKey, messages);
105
134
  return;
106
135
  }
107
136
  const trimmed = messages.slice(-ASK_AI_HISTORY_LIMIT);
108
- chatHistories.set(tickerSymbol, trimmed);
137
+ chatHistories.set(historyKey, trimmed);
138
+ if (persistedConversation && sameMessages(persistedConversation.messages, trimmed)) return;
109
139
  setPersistedConversation({
110
140
  updatedAt: Date.now(),
111
141
  messages: trimmed,
112
142
  });
113
- }, [currentProvider, messages, setPersistedConversation, tickerSymbol]);
143
+ }, [currentProvider, historyKey, messages, persistedConversation, setPersistedConversation, tickerSymbol]);
114
144
 
115
145
  useEffect(() => {
116
146
  const sb = scrollRef.current;
@@ -65,23 +65,6 @@ export function resolveDefaultAiProviderId(providers = detectProviders()): strin
65
65
  return getAvailableProviders(providers)[0]?.id ?? providers[0]?.id ?? "claude";
66
66
  }
67
67
 
68
- export function listAiProviderOptions(
69
- preferredId?: string | null,
70
- providers = detectProviders(),
71
- ): Array<{ label: string; value: string }> {
72
- const ordered = [...providers].sort((left, right) => {
73
- if (left.id === preferredId) return -1;
74
- if (right.id === preferredId) return 1;
75
- if (left.available !== right.available) return left.available ? -1 : 1;
76
- return left.name.localeCompare(right.name);
77
- });
78
-
79
- return ordered.map((provider) => ({
80
- value: provider.id,
81
- label: provider.available ? provider.name : `${provider.name} (missing)`,
82
- }));
83
- }
84
-
85
68
  export function __setDetectedProvidersForTests(providers: AiProvider[] | null): void {
86
69
  detectedProviders = providers;
87
70
  }
@@ -1,7 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { act, useReducer, useState } from "react";
3
- import { testRender } from "../../../renderers/opentui/test-utils";
4
- import { DialogProvider } from "@opentui-ui/dialog/react";
3
+ import { TestDialogProvider, testRender } from "../../../renderers/opentui/test-utils";
5
4
  import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane-footer";
6
5
  import { AppContext, PaneInstanceProvider, appReducer, createInitialState } from "../../../state/app-context";
7
6
  import { createDefaultConfig } from "../../../types/config";
@@ -121,7 +120,7 @@ function ScreenerHarness({
121
120
 
122
121
  return (
123
122
  <AppContext value={{ state, dispatch }}>
124
- <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
123
+ <TestDialogProvider>
125
124
  <PaneInstanceProvider paneId={PANE_ID}>
126
125
  <PluginRenderProvider pluginId="ai" runtime={runtime}>
127
126
  <PaneFooterProvider>
@@ -134,7 +133,7 @@ function ScreenerHarness({
134
133
  </PaneFooterProvider>
135
134
  </PluginRenderProvider>
136
135
  </PaneInstanceProvider>
137
- </DialogProvider>
136
+ </TestDialogProvider>
138
137
  </AppContext>
139
138
  );
140
139
  }
@@ -461,8 +461,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
461
461
  const selectedResult = activeTab?.results.find((result) => result.symbol === cursorSymbol)
462
462
  ?? activeTab?.results[0]
463
463
  ?? null;
464
- const selectedIdx = sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol);
465
- const safeSelectedIdx = selectedIdx >= 0 ? selectedIdx : 0;
466
464
  const isRunningActiveTab = runState?.tabId === activeTab?.id;
467
465
  const forceRunArmed = !!activeTab && forceConfirmTabId === activeTab.id;
468
466
 
@@ -753,7 +751,6 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
753
751
  return;
754
752
  }
755
753
 
756
- const key = event.name;
757
754
  if (event.name === "t") {
758
755
  addTab();
759
756
  return;
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act } from "react";
2
+ import { act, useReducer } from "react";
3
3
  import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
4
4
  import { testRender } from "../../renderers/opentui/test-utils";
5
- import { AppContext, PaneInstanceProvider, createInitialState } from "../../state/app-context";
5
+ import { AppContext, PaneInstanceProvider, appReducer, createInitialState } from "../../state/app-context";
6
6
  import { createStatefulTestPluginRuntime } from "../../test-support/plugin-runtime";
7
7
  import { createDefaultConfig } from "../../types/config";
8
8
  import { Box } from "../../ui";
@@ -258,4 +258,91 @@ describe("AskAiTab", () => {
258
258
 
259
259
  expect(opened).toEqual(["AAPL"]);
260
260
  });
261
+
262
+ test("does not persist previous ticker messages while hydrating a newly selected ticker", async () => {
263
+ setProviders([
264
+ { id: "claude", name: "Claude", command: "claude", available: true, buildArgs: () => [] },
265
+ ]);
266
+
267
+ const runtime = createStatefulTestPluginRuntime();
268
+ runtime.setResumeState("ai", "conversation:claude:AAPL", {
269
+ updatedAt: Date.now(),
270
+ messages: [
271
+ { role: "user", content: "AAPL question" },
272
+ { role: "assistant", content: "AAPL answer", loading: false },
273
+ ],
274
+ }, 1);
275
+ runtime.setResumeState("ai", "conversation:claude:MSFT", {
276
+ updatedAt: Date.now(),
277
+ messages: [
278
+ { role: "user", content: "MSFT question" },
279
+ { role: "assistant", content: "MSFT answer", loading: false },
280
+ ],
281
+ }, 1);
282
+
283
+ let selectSymbol: ((symbol: string) => void) | null = null;
284
+
285
+ const config = createDefaultConfig("/tmp/gloomberb-ai-navigation");
286
+ const initialState = createInitialState(config);
287
+ initialState.focusedPaneId = PANE_ID;
288
+ initialState.paneState["portfolio-list:main"] = {
289
+ ...initialState.paneState["portfolio-list:main"],
290
+ cursorSymbol: "AAPL",
291
+ };
292
+ initialState.tickers = new Map([
293
+ ["AAPL", makeTicker("AAPL", "Apple Inc.")],
294
+ ["MSFT", makeTicker("MSFT", "Microsoft Corporation")],
295
+ ]);
296
+
297
+ function NavigableAskAiHarness() {
298
+ const [state, dispatch] = useReducer(appReducer, initialState);
299
+ selectSymbol = (symbol) => {
300
+ dispatch({
301
+ type: "UPDATE_PANE_STATE",
302
+ paneId: "portfolio-list:main",
303
+ patch: { cursorSymbol: symbol },
304
+ });
305
+ };
306
+
307
+ return (
308
+ <AppContext value={{ state, dispatch }}>
309
+ <PaneInstanceProvider paneId={PANE_ID}>
310
+ <PluginRenderProvider pluginId="ai" runtime={runtime}>
311
+ <PaneFooterProvider>
312
+ {(footer) => (
313
+ <Box flexDirection="column" width={60} height={12}>
314
+ <AskAiTab width={60} height={11} focused onCapture={() => {}} />
315
+ <PaneFooterBar footer={footer} focused width={60} />
316
+ </Box>
317
+ )}
318
+ </PaneFooterProvider>
319
+ </PluginRenderProvider>
320
+ </PaneInstanceProvider>
321
+ </AppContext>
322
+ );
323
+ }
324
+
325
+ await act(async () => {
326
+ testSetup = await testRender(<NavigableAskAiHarness />, {
327
+ width: 60,
328
+ height: 12,
329
+ });
330
+ });
331
+ await flushFrame();
332
+
333
+ expect(testSetup.captureCharFrame()).toContain("AAPL answer");
334
+
335
+ await act(() => {
336
+ selectSymbol?.("MSFT");
337
+ });
338
+ await flushFrame();
339
+
340
+ const frame = testSetup.captureCharFrame();
341
+ expect(frame).toContain("MSFT answer");
342
+ expect(frame).not.toContain("AAPL answer");
343
+ expect((runtime.getResumeState("ai", "conversation:claude:MSFT") as any)?.messages).toEqual([
344
+ { role: "user", content: "MSFT question" },
345
+ { role: "assistant", content: "MSFT answer", loading: false },
346
+ ]);
347
+ });
261
348
  });
@@ -65,7 +65,7 @@ function Harness({
65
65
  const runtime = createTestPluginRuntime({
66
66
  getBrokerAdapter: (brokerType) => brokerType === "ibkr" ? ibkrBroker : null,
67
67
  openCommandBar: (query) => calls.push(`command:${query ?? ""}`),
68
- showWidget: (paneId) => calls.push(`widget:${paneId}`),
68
+ showPane: (paneId) => calls.push(`pane:${paneId}`),
69
69
  connectBrokerInstance: async (instanceId) => { calls.push(`connect:${instanceId}`); },
70
70
  syncBrokerInstance: async (instanceId) => { calls.push(`sync:${instanceId}`); },
71
71
  updateBrokerInstance: async (instanceId) => { calls.push(`update:${instanceId}`); },
@@ -163,6 +163,6 @@ describe("BrokersPane", () => {
163
163
  await pressKey("s");
164
164
  await pressKey("o");
165
165
 
166
- expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "widget:ibkr-trading"]);
166
+ expect(calls).toEqual(["connect:ibkr-paper", "sync:ibkr-paper", "pane:ibkr-trading"]);
167
167
  });
168
168
  });
@@ -2,6 +2,7 @@ import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  Button,
5
+ ConfirmDialog,
5
6
  DataTableStackView,
6
7
  NumberField,
7
8
  SegmentedControl,
@@ -29,8 +30,7 @@ import type { BrokerAdapter, BrokerConfigField, BrokerProfileAction } from "../.
29
30
  import type { BrokerInstanceConfig } from "../../../types/config";
30
31
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
31
32
  import type { BrokerAccount } from "../../../types/trading";
32
- import { DialogFrame } from "../../../components/ui/frame";
33
- import { useDialog, useDialogKeyboard, type PromptContext } from "../../../ui/dialog";
33
+ import { useDialog, type PromptContext } from "../../../ui/dialog";
34
34
  import { formatCurrency } from "../../../utils/format";
35
35
  import { usePluginAppActions, usePluginBrokerActions } from "../../plugin-runtime";
36
36
  import {
@@ -76,34 +76,6 @@ function isBrokerErrorMessage(message: string | null | undefined): boolean {
76
76
  return normalized.includes("failed") || normalized.includes("required");
77
77
  }
78
78
 
79
- function ConfirmRemoveBrokerDialog({
80
- resolve,
81
- row,
82
- }: PromptContext<boolean> & { row: BrokerProfileRow }) {
83
- const confirm = useCallback(() => resolve(true), [resolve]);
84
- const cancel = useCallback(() => resolve(false), [resolve]);
85
-
86
- useDialogKeyboard((event) => {
87
- event.stopPropagation();
88
- if (event.name === "enter" || event.name === "return") confirm();
89
- if (event.name === "escape") cancel();
90
- });
91
-
92
- return (
93
- <DialogFrame title="Disconnect broker?" footer="Enter disconnect · Esc cancel">
94
- <Box flexDirection="column" width={58}>
95
- <Text fg={colors.text}>{`Remove "${row.label}" and imported broker data?`}</Text>
96
- <Text fg={colors.textDim}>Broker-managed portfolios, positions, and contracts will be removed.</Text>
97
- <Box height={1} />
98
- <Box flexDirection="row" gap={1}>
99
- <Button label="Disconnect" variant="danger" onPress={confirm} />
100
- <Button label="Back" variant="secondary" onPress={cancel} />
101
- </Box>
102
- </Box>
103
- </DialogFrame>
104
- );
105
- }
106
-
107
79
  function brokerFieldLabel(field: BrokerConfigField, focused: boolean): string {
108
80
  return focused ? `> ${field.label}` : ` ${field.label}`;
109
81
  }
@@ -202,7 +174,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
202
174
  const config = useAppSelector((state) => state.config);
203
175
  const brokerAccounts = useAppSelector((state) => state.brokerAccounts);
204
176
  const dialog = useDialog();
205
- const { openCommandBar, showWidget } = usePluginAppActions();
177
+ const { openCommandBar, showPane } = usePluginAppActions();
206
178
  const {
207
179
  getBrokerAdapter,
208
180
  connectBrokerInstance,
@@ -363,15 +335,26 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
363
335
  setMessage(action.disabledReason ?? `${action.label} is unavailable for this profile.`);
364
336
  return;
365
337
  }
366
- if (action.widgetId) showWidget(action.widgetId);
367
- }, [primaryProfileAction, showWidget]);
338
+ if (action.paneId) showPane(action.paneId);
339
+ }, [primaryProfileAction, showPane]);
368
340
 
369
341
  const removeSelected = useCallback(async () => {
370
342
  if (!selectedRow) return;
371
343
  const confirmed = await dialog.prompt<boolean>({
372
344
  closeOnClickOutside: true,
373
345
  content: (ctx: PromptContext<boolean>) => (
374
- <ConfirmRemoveBrokerDialog {...ctx} row={selectedRow} />
346
+ <ConfirmDialog
347
+ {...ctx}
348
+ title="Disconnect broker?"
349
+ body={[
350
+ `Remove "${selectedRow.label}" and imported broker data?`,
351
+ "Broker-managed portfolios, positions, and contracts will be removed.",
352
+ ]}
353
+ confirmLabel="Disconnect"
354
+ cancelLabel="Back"
355
+ width={58}
356
+ footer="Enter disconnect · Esc cancel"
357
+ />
375
358
  ),
376
359
  }).catch(() => false);
377
360
  if (confirmed !== true) return;
@@ -392,7 +375,7 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
392
375
  const hasSelectedRow = selectedRow !== null;
393
376
  const selectedHasAdapter = !!selectedRow?.adapter;
394
377
  const canUseSelectedBroker = selectedHasAdapter && !busy;
395
- const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.widgetId);
378
+ const canOpenSelectedAction = selectedProfileActions.some((action) => !action.disabled && action.paneId);
396
379
  const canRemoveSelected = hasSelectedRow && !busy;
397
380
 
398
381
  const footerActionsRef = useRef({
@@ -752,7 +735,7 @@ export const brokerManagerPlugin: GloomPlugin = {
752
735
  keywords: ["broker", "brokers", "connection", "accounts", "sync"],
753
736
  category: "navigation",
754
737
  execute: () => {
755
- ctx.showWidget("brokers");
738
+ ctx.showPane("brokers");
756
739
  },
757
740
  });
758
741
  },