gloomberb 0.4.1 → 0.5.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 (207) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -325,7 +325,7 @@ export function getScreenFooterLeft(route: CommandBarRoute | null): string {
325
325
  }
326
326
 
327
327
  export function getScreenFooterRight(route: CommandBarRoute | null): string {
328
- if (!route) return "esc close";
328
+ if (!route) return "esc cancel";
329
329
 
330
330
  if (route.kind === "workflow") return "esc back";
331
331
  if (route.kind === "confirm") return "backspace/esc back";
@@ -1,4 +1,4 @@
1
- import type { PaneTemplateDef } from "../../types/plugin";
1
+ import type { CommandDef, PaneTemplateDef } from "../../types/plugin";
2
2
  import type { Command } from "./command-registry";
3
3
  import { getPaneTemplateDisplayLabel } from "./pane-template-display";
4
4
 
@@ -26,10 +26,16 @@ export interface PaneTemplateShortcutIntent extends ShortcutIntentBase {
26
26
  template: PaneTemplateDef;
27
27
  }
28
28
 
29
+ export interface PluginCommandShortcutIntent extends ShortcutIntentBase {
30
+ source: "plugin-command";
31
+ command: CommandDef;
32
+ }
33
+
29
34
  export type ShortcutIntent =
30
35
  | { kind: "none" }
31
36
  | CommandShortcutIntent
32
- | PaneTemplateShortcutIntent;
37
+ | PaneTemplateShortcutIntent
38
+ | PluginCommandShortcutIntent;
33
39
 
34
40
  interface ShortcutParseCandidate {
35
41
  prefix: string;
@@ -37,8 +43,9 @@ interface ShortcutParseCandidate {
37
43
  description: string;
38
44
  argKind: RootShortcutArgKind | null;
39
45
  argPlaceholder?: string;
40
- source: "command" | "pane-template";
46
+ source: "command" | "pane-template" | "plugin-command";
41
47
  command?: Command;
48
+ pluginCommand?: CommandDef;
42
49
  template?: PaneTemplateDef;
43
50
  }
44
51
 
@@ -71,6 +78,7 @@ function inferShortcutArg(argKind: RootShortcutArgKind | null, activeTicker: str
71
78
 
72
79
  function buildShortcutCandidates(
73
80
  commands: Command[],
81
+ pluginCommands: CommandDef[],
74
82
  paneTemplates: PaneTemplateDef[],
75
83
  ): ShortcutParseCandidate[] {
76
84
  return [
@@ -85,6 +93,17 @@ function buildShortcutCandidates(
85
93
  source: "command" as const,
86
94
  command,
87
95
  })),
96
+ ...pluginCommands
97
+ .filter((command) => command.shortcut?.trim().length)
98
+ .map((command) => ({
99
+ prefix: normalizeShortcutPrefix(command.shortcut!),
100
+ label: command.label,
101
+ description: command.description ?? "",
102
+ argKind: command.shortcutArg?.kind ?? (command.shortcutArg ? "text" : null),
103
+ argPlaceholder: command.shortcutArg?.placeholder,
104
+ source: "plugin-command" as const,
105
+ pluginCommand: command,
106
+ })),
88
107
  ...paneTemplates
89
108
  .filter((template) => template.shortcut?.prefix)
90
109
  .map((template) => ({
@@ -102,11 +121,13 @@ function buildShortcutCandidates(
102
121
  export function parseRootShortcutIntent({
103
122
  query,
104
123
  commands,
124
+ pluginCommands = [],
105
125
  paneTemplates,
106
126
  activeTicker,
107
127
  }: {
108
128
  query: string;
109
129
  commands: Command[];
130
+ pluginCommands?: CommandDef[];
110
131
  paneTemplates: PaneTemplateDef[];
111
132
  activeTicker: string | null;
112
133
  }): ShortcutIntent {
@@ -114,7 +135,7 @@ export function parseRootShortcutIntent({
114
135
  if (!trimmed) return { kind: "none" };
115
136
 
116
137
  const upper = trimmed.toUpperCase();
117
- const match = buildShortcutCandidates(commands, paneTemplates).find((candidate) => (
138
+ const match = buildShortcutCandidates(commands, pluginCommands, paneTemplates).find((candidate) => (
118
139
  upper === candidate.prefix || upper.startsWith(`${candidate.prefix} `)
119
140
  ));
120
141
  if (!match) return { kind: "none" };
@@ -151,6 +172,14 @@ export function parseRootShortcutIntent({
151
172
  };
152
173
  }
153
174
 
175
+ if (match.source === "plugin-command" && match.pluginCommand) {
176
+ return {
177
+ ...base,
178
+ source: "plugin-command",
179
+ command: match.pluginCommand,
180
+ };
181
+ }
182
+
154
183
  return {
155
184
  ...base,
156
185
  source: "pane-template",
@@ -45,15 +45,15 @@ describe("command bar view model helpers", () => {
45
45
  test("returns footer hints for plugin toggles", () => {
46
46
  expect(getFooterHints("plugins", false)).toEqual({
47
47
  left: "up/down move enter select space toggle",
48
- right: "esc close",
48
+ right: "esc cancel",
49
49
  });
50
50
  expect(getFooterHints("layout", false)).toEqual({
51
51
  left: "up/down move enter select",
52
- right: "esc close",
52
+ right: "esc cancel",
53
53
  });
54
54
  expect(getFooterHints("new-pane", false)).toEqual({
55
55
  left: "up/down move enter select",
56
- right: "esc close",
56
+ right: "esc cancel",
57
57
  });
58
58
  });
59
59
 
@@ -111,16 +111,16 @@ export function getFooterHints(mode: CommandBarMode, isNarrow: boolean): Command
111
111
  if (mode === "plugins") {
112
112
  return {
113
113
  left: isNarrow ? "space toggle" : `${moveAndSelect} space toggle`,
114
- right: "esc close",
114
+ right: "esc cancel",
115
115
  };
116
116
  }
117
117
  if (mode === "layout") {
118
- return { left: moveAndSelect, right: "esc close" };
118
+ return { left: moveAndSelect, right: "esc cancel" };
119
119
  }
120
120
  if (mode === "new-pane") {
121
- return { left: moveAndSelect, right: "esc close" };
121
+ return { left: moveAndSelect, right: "esc cancel" };
122
122
  }
123
- return { left: moveAndSelect, right: "esc close" };
123
+ return { left: moveAndSelect, right: "esc cancel" };
124
124
  }
125
125
 
126
126
  export function getEmptyState(mode: CommandBarMode, query: string, searchQuery?: string): { label: string; detail: string } {
@@ -152,6 +152,16 @@ export function resolvePreferredCollectionTarget(
152
152
  return options.some((option) => option.id === activeCollectionId) ? activeCollectionId : null;
153
153
  }
154
154
 
155
+ export function resolveSoleCollectionTarget(
156
+ state: AppState,
157
+ kind: CollectionKind,
158
+ action: CollectionMembershipAction,
159
+ ticker: TickerRecord | null,
160
+ ): string | null {
161
+ const options = getCollectionTargetOptions(state, kind, action, ticker);
162
+ return options.length === 1 ? options[0]!.id : null;
163
+ }
164
+
155
165
  export async function resolveTickerInput(
156
166
  rawInput: string | undefined,
157
167
  activeTicker: string | null,
@@ -7,6 +7,7 @@ import { useAppState } from "../../state/app-context";
7
7
  import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
8
8
  import { useQuoteEntry, useResolvedEntryValue } from "../../market-data/hooks";
9
9
  import { formatPercentRaw } from "../../utils/format";
10
+ import { formatMarketPrice } from "../../utils/market-format";
10
11
  import { marketStateLabel, marketStateColor, getExtendedHoursInfo } from "../../utils/market-status";
11
12
  import { VERSION } from "../../version";
12
13
 
@@ -103,7 +104,7 @@ export function Header() {
103
104
 
104
105
  const spyColor = spyQuote ? priceColor(spyQuote.change) : colors.headerText;
105
106
  const spyText = spyQuote
106
- ? `SPY ${spyQuote.price.toFixed(2)} ${formatPercentRaw(spyQuote.changePercent)}`
107
+ ? `SPY ${formatMarketPrice(spyQuote.price, { assetCategory: "ETF" })} ${formatPercentRaw(spyQuote.changePercent)}`
107
108
  : "SPY —";
108
109
 
109
110
  // Extended hours info
@@ -1,19 +1,23 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { TextAttributes } from "@opentui/core";
2
3
  import { testRender } from "@opentui/react/test-utils";
3
4
  import { DialogProvider } from "@opentui-ui/dialog/react";
4
5
  import type { ReactNode } from "react";
5
6
  import { act, useReducer } from "react";
6
7
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
8
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
9
+ import { setSharedDataProviderForTests } from "../../plugins/registry";
7
10
  import { cloneLayout, createDefaultConfig } from "../../types/config";
8
11
  import type { PluginRegistry } from "../../plugins/registry";
9
12
  import { setSharedRegistryForTests } from "../../plugins/registry";
10
13
  import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
11
14
  import type { PaneProps } from "../../types/plugin";
15
+ import { StockChart } from "../chart/stock-chart";
12
16
  import { StatusBar } from "./status-bar";
13
17
  import { Header } from "./header";
14
18
  import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
15
19
  import type { DataProvider } from "../../types/data-provider";
16
- import type { Quote } from "../../types/financials";
20
+ import type { PricePoint, Quote, TickerFinancials } from "../../types/financials";
17
21
  import type { TickerRecord } from "../../types/ticker";
18
22
 
19
23
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
@@ -25,6 +29,8 @@ afterEach(() => {
25
29
  testSetup = undefined;
26
30
  }
27
31
  harnessState = null;
32
+ setSharedMarketDataCoordinator(null);
33
+ setSharedDataProviderForTests(undefined);
28
34
  setSharedRegistryForTests(undefined);
29
35
  });
30
36
 
@@ -134,31 +140,42 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
134
140
  };
135
141
  }
136
142
 
137
- function createHeaderDataProvider(): DataProvider {
143
+ function makePriceHistory(
144
+ length: number,
145
+ startDate = new Date(Date.UTC(2025, 0, 1)),
146
+ ): PricePoint[] {
147
+ return Array.from({ length }, (_, index) => ({
148
+ date: new Date(startDate.getTime() + index * 24 * 3600_000),
149
+ open: 100 + index * 0.2,
150
+ high: 101 + index * 0.2,
151
+ low: 99 + index * 0.2,
152
+ close: 100.5 + index * 0.2,
153
+ volume: 1_000 + index * 10,
154
+ }));
155
+ }
156
+
157
+ function createChartShellDataProvider(historyBySymbol: Record<string, PricePoint[]>): DataProvider {
138
158
  return {
139
- id: "test",
140
- name: "Test",
141
- async getTickerFinancials() {
142
- return { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote({ symbol: "SPY", name: "SPY" }) };
143
- },
144
- async getQuote() {
145
- return makeQuote({ symbol: "SPY", name: "SPY" });
146
- },
147
- async getExchangeRate() {
148
- return 1;
149
- },
150
- async search() {
151
- return [];
152
- },
153
- async getNews() {
154
- return [];
155
- },
156
- async getArticleSummary() {
157
- return null;
158
- },
159
- async getPriceHistory() {
160
- return [];
161
- },
159
+ id: "shell-test-provider",
160
+ name: "Shell Test Provider",
161
+ getTickerFinancials: async (symbol) => ({
162
+ annualStatements: [],
163
+ quarterlyStatements: [],
164
+ priceHistory: historyBySymbol[symbol] ?? [],
165
+ }),
166
+ getQuote: async (symbol) => ({
167
+ symbol,
168
+ price: historyBySymbol[symbol]?.[historyBySymbol[symbol]!.length - 1]?.close ?? 0,
169
+ currency: "USD",
170
+ change: 0,
171
+ changePercent: 0,
172
+ lastUpdated: Date.now(),
173
+ }),
174
+ getExchangeRate: async () => 1,
175
+ search: async () => [],
176
+ getNews: async () => [],
177
+ getArticleSummary: async () => null,
178
+ getPriceHistory: async (symbol) => historyBySymbol[symbol] ?? [],
162
179
  };
163
180
  }
164
181
 
@@ -256,7 +273,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
256
273
  return (
257
274
  <AppContext value={{ state, dispatch }}>
258
275
  <box flexDirection="column">
259
- <Header dataProvider={createHeaderDataProvider()} />
276
+ <Header />
260
277
  <Shell pluginRegistry={pluginRegistry} />
261
278
  <StatusBar />
262
279
  </box>
@@ -264,6 +281,36 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
264
281
  );
265
282
  }
266
283
 
284
+ function ChartShellHarness({
285
+ pluginRegistry,
286
+ config,
287
+ ticker,
288
+ financials,
289
+ focusedPaneId,
290
+ }: {
291
+ pluginRegistry: PluginRegistry;
292
+ config: ReturnType<typeof createDefaultConfig>;
293
+ ticker: TickerRecord;
294
+ financials: TickerFinancials;
295
+ focusedPaneId: string | null;
296
+ }) {
297
+ const initialState = createInitialState(config);
298
+ initialState.focusedPaneId = focusedPaneId;
299
+ initialState.tickers = new Map([[ticker.metadata.ticker, ticker]]);
300
+ initialState.financials = new Map([[ticker.metadata.ticker, financials]]);
301
+
302
+ const [state, dispatch] = useReducer(appReducer, initialState);
303
+ harnessState = state;
304
+
305
+ return (
306
+ <AppContext value={{ state, dispatch }}>
307
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
308
+ <Shell pluginRegistry={pluginRegistry} />
309
+ </DialogProvider>
310
+ </AppContext>
311
+ );
312
+ }
313
+
267
314
  describe("Header", () => {
268
315
  test("shows automatic self-update status for standalone binaries", async () => {
269
316
  testSetup = await testRender(
@@ -524,13 +571,98 @@ describe("Shell", () => {
524
571
  expect(testSetup.captureCharFrame()).toContain("Settings");
525
572
  });
526
573
 
574
+ test("applies a chart resolution chip on the first click even when the chart pane is unfocused", async () => {
575
+ const symbol = "AAPL";
576
+ const config = createDefaultConfig("/tmp/gloomberb-shell-chart-test");
577
+ config.layout = cloneLayout(config.layout);
578
+ config.layouts = [{ name: "Default", layout: cloneLayout(config.layout) }];
579
+ config.layout.instances = config.layout.instances.map((instance) => (
580
+ instance.instanceId === "ticker-detail:main"
581
+ ? {
582
+ ...instance,
583
+ binding: { kind: "fixed" as const, symbol },
584
+ settings: {
585
+ ...(instance.settings ?? {}),
586
+ chartResolution: "auto",
587
+ chartRangePreset: "5Y",
588
+ },
589
+ }
590
+ : instance
591
+ ));
592
+
593
+ const ticker = makeTicker({ ticker: symbol, name: symbol });
594
+ const history = makePriceHistory(260);
595
+ const financials: TickerFinancials = {
596
+ annualStatements: [],
597
+ quarterlyStatements: [],
598
+ priceHistory: history,
599
+ };
600
+
601
+ const provider = createChartShellDataProvider({ [symbol]: history });
602
+ setSharedDataProviderForTests(provider);
603
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
604
+
605
+ const pluginRegistry = createShellPluginRegistry({
606
+ tickerDetailComponent: (props) => (
607
+ <StockChart width={props.width} height={props.height} focused={props.focused} />
608
+ ),
609
+ });
610
+
611
+ testSetup = await testRender(
612
+ <ChartShellHarness
613
+ pluginRegistry={pluginRegistry}
614
+ config={config}
615
+ ticker={ticker}
616
+ financials={financials}
617
+ focusedPaneId="portfolio-list:main"
618
+ />,
619
+ { width: 120, height: 32 },
620
+ );
621
+
622
+ await testSetup.renderOnce();
623
+ await testSetup.renderOnce();
624
+
625
+ const initialLines = testSetup.captureCharFrame().split("\n");
626
+ const resolutionRow = initialLines.findIndex((line) => line.includes("AUTO 1M 5M 15M"));
627
+ const targetResolution = "1W";
628
+ expect(resolutionRow).toBeGreaterThanOrEqual(0);
629
+ const resolutionSpansBefore = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
630
+ let resolutionCol = -1;
631
+ let spanColumn = 0;
632
+ for (const span of resolutionSpansBefore) {
633
+ const chipOffset = span.text.indexOf(targetResolution);
634
+ if (chipOffset >= 0) {
635
+ resolutionCol = spanColumn + chipOffset + Math.floor(targetResolution.length / 2);
636
+ break;
637
+ }
638
+ spanColumn += span.width;
639
+ }
640
+ expect(resolutionCol).toBeGreaterThanOrEqual(0);
641
+ expect(testSetup.captureCharFrame()).toContain("AAPL - AUTO");
642
+
643
+ await act(async () => {
644
+ await testSetup!.mockMouse.click(resolutionCol, resolutionRow);
645
+ await testSetup!.renderOnce();
646
+ });
647
+ await testSetup.renderOnce();
648
+ await testSetup.renderOnce();
649
+
650
+ expect(
651
+ harnessState?.config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main")?.settings?.chartResolution,
652
+ ).toBe("1wk");
653
+ const resolutionSpans = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
654
+ expect(
655
+ resolutionSpans.some((span) => span.text === targetResolution && (span.attributes & TextAttributes.BOLD) !== 0),
656
+ ).toBe(true);
657
+ });
658
+
527
659
  test("keeps the cash drawer and gridlock tip on distinct click rows in the full app layout", async () => {
528
660
  const pluginRegistry = createBrokerPortfolioRegistry();
529
661
  const layoutUpdates: unknown[] = [];
530
662
  const toasts: string[] = [];
531
663
  pluginRegistry.getLayoutFn = () => harnessState?.config.layout ?? { dockRoot: null, instances: [], floating: [] };
532
664
  pluginRegistry.updateLayoutFn = (layout) => { layoutUpdates.push(layout); };
533
- pluginRegistry.showToastFn = (message: string) => { toasts.push(message); };
665
+ pluginRegistry.notify = ({ body }: { body: string }) => { toasts.push(body); };
534
666
  setSharedRegistryForTests(pluginRegistry);
535
667
 
536
668
  testSetup = await testRender(
@@ -676,6 +808,33 @@ describe("Shell", () => {
676
808
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
677
809
  });
678
810
 
811
+ test("keeps a floating pane at the preview rect after a free drag release", () => {
812
+ const config = createDefaultConfig("/tmp/gloomberb-shell-drag-test");
813
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
814
+ if (!detailPane) throw new Error("missing detail pane");
815
+ const floatingOnlyLayout = {
816
+ dockRoot: null,
817
+ instances: [{ ...detailPane, binding: { kind: "fixed" as const, symbol: "AAPL" } }],
818
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
819
+ };
820
+
821
+ const result = finalizePaneDragRelease(
822
+ floatingOnlyLayout,
823
+ "ticker-detail:main",
824
+ { x: 14, y: 4, width: 30, height: 8 },
825
+ null,
826
+ );
827
+
828
+ expect(result.shouldShowGridlockTip).toBe(false);
829
+ expect(result.nextLayout.floating[0]).toEqual(expect.objectContaining({
830
+ instanceId: "ticker-detail:main",
831
+ x: 14,
832
+ y: 4,
833
+ width: 30,
834
+ height: 8,
835
+ }));
836
+ });
837
+
679
838
  test("keeps the last floating pane body row visible above the border", async () => {
680
839
  const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
681
840
  const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
@@ -19,6 +19,7 @@ import {
19
19
  resolveFloating,
20
20
  simulateDrop,
21
21
  swapPanes,
22
+ type DockDividerLayout,
22
23
  type DockLeafLayout,
23
24
  type DropTarget,
24
25
  type FloatingRect,
@@ -778,8 +779,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
778
779
  event.preventDefault();
779
780
  return;
780
781
  }
781
- event.stopPropagation();
782
- event.preventDefault();
782
+ // Focus floating panes without consuming body clicks so child controls
783
+ // can act on the same initial mouse interaction.
783
784
  return;
784
785
  }
785
786
 
@@ -837,8 +838,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
837
838
  event.preventDefault();
838
839
  return;
839
840
  }
840
- event.stopPropagation();
841
- event.preventDefault();
841
+ // Focus docked panes without consuming body clicks so embedded widgets
842
+ // receive the first click instead of requiring a second one.
842
843
  return;
843
844
  }
844
845
 
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { testRender } from "@opentui/react/test-utils";
3
3
  import { AppContext, createInitialState } from "../../state/app-context";
4
- import { cloneLayout, createDefaultConfig } from "../../types/config";
4
+ import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../types/config";
5
+ import type { AppNotificationRequest } from "../../types/plugin";
5
6
  import { StatusBar } from "./status-bar";
6
7
  import { setSharedRegistryForTests } from "../../plugins/registry";
7
8
 
@@ -89,13 +90,13 @@ describe("StatusBar", () => {
89
90
 
90
91
  const actions: Array<{ type: string }> = [];
91
92
  let updatedLayout = null as ReturnType<typeof cloneLayout> | null;
92
- const toasts: string[] = [];
93
+ const notifications: AppNotificationRequest[] = [];
93
94
 
94
95
  setSharedRegistryForTests({
95
96
  getLayoutFn: () => state.config.layout,
96
97
  getTermSizeFn: () => ({ width: 120, height: 40 }),
97
- updateLayoutFn: (layout) => { updatedLayout = layout; },
98
- showToastFn: (message: string) => { toasts.push(message); },
98
+ updateLayoutFn: (layout: LayoutConfig) => { updatedLayout = layout; },
99
+ notify: (notification: AppNotificationRequest) => { notifications.push(notification); },
99
100
  Slot: () => null,
100
101
  } as any);
101
102
 
@@ -119,8 +120,16 @@ describe("StatusBar", () => {
119
120
  await testSetup.renderOnce();
120
121
 
121
122
  expect(updatedLayout?.floating).toHaveLength(0);
122
- expect(toasts).toEqual(["Retiled all panes"]);
123
+ expect(notifications).toHaveLength(1);
124
+ expect(notifications[0]).toMatchObject({
125
+ body: "Retiled all panes",
126
+ type: "success",
127
+ action: { label: "Revert" },
128
+ });
123
129
  expect(actions).toContainEqual({ type: "DISMISS_GRIDLOCK_TIP" });
130
+
131
+ notifications[0]!.action!.onClick();
132
+ expect(actions).toContainEqual({ type: "UNDO_LAYOUT" });
124
133
  });
125
134
 
126
135
  test("auto-dismisses the gridlock tip after its timeout", async () => {
@@ -136,7 +145,7 @@ describe("StatusBar", () => {
136
145
  const originalSetTimeout = globalThis.setTimeout;
137
146
  const originalClearTimeout = globalThis.clearTimeout;
138
147
 
139
- globalThis.setTimeout = ((callback: TimerHandler, delay?: number) => {
148
+ globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number) => {
140
149
  timers.push({ callback: typeof callback === "function" ? callback : null, delay });
141
150
  return 1 as unknown as ReturnType<typeof setTimeout>;
142
151
  }) as typeof setTimeout;
@@ -146,7 +155,7 @@ describe("StatusBar", () => {
146
155
  getLayoutFn: () => state.config.layout,
147
156
  getTermSizeFn: () => ({ width: 120, height: 40 }),
148
157
  updateLayoutFn: () => {},
149
- showToastFn: () => {},
158
+ notify: () => {},
150
159
  Slot: () => null,
151
160
  } as any);
152
161
 
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../utils/market-status";
11
11
  import { getSharedRegistry } from "../../plugins/registry";
12
12
  import { gridlockAllPanes } from "../../plugins/pane-manager";
13
+ import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
13
14
 
14
15
  const GRIDLOCK_TIP_DURATION_MS = 60_000;
15
16
 
@@ -60,7 +61,9 @@ export function StatusBar() {
60
61
  if (!registry) return;
61
62
  const { width, height } = registry.getTermSizeFn();
62
63
  registry.updateLayoutFn(gridlockAllPanes(registry.getLayoutFn(), { x: 0, y: 0, width, height }));
63
- registry.showToastFn("Retiled all panes", { type: "success" });
64
+ notifyGridlockComplete(registry.notify.bind(registry), () => {
65
+ dispatch({ type: "UNDO_LAYOUT" });
66
+ });
64
67
  dispatch({ type: "DISMISS_GRIDLOCK_TIP" });
65
68
  };
66
69
 
@@ -129,6 +129,7 @@ export function MarkdownEditor({
129
129
  placeholderColor={colors.textDim}
130
130
  backgroundColor={focused ? colors.panel : colors.bg}
131
131
  flexGrow={1}
132
+ wrapText
132
133
  />
133
134
  );
134
135
  }