gloomberb 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) 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/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. 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,
@@ -1,7 +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
+ import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
5
5
 
6
6
  interface FloatingPaneWrapperProps {
7
7
  title: string;
@@ -22,6 +22,7 @@ export function FloatingPaneWrapper({
22
22
  }: FloatingPaneWrapperProps) {
23
23
  const bg = floatingPaneBg(focused);
24
24
  const bodyHeight = getPaneBodyHeight(height);
25
+ const bodyInset = getPaneBodyHorizontalInset(focused);
25
26
 
26
27
  return (
27
28
  <box
@@ -38,7 +39,7 @@ export function FloatingPaneWrapper({
38
39
  <PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
39
40
 
40
41
  {/* Content */}
41
- <box height={bodyHeight} overflow="hidden">
42
+ <box height={bodyHeight} overflow="hidden" paddingLeft={bodyInset} paddingRight={bodyInset}>
42
43
  {children}
43
44
  </box>
44
45
 
@@ -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,6 +1,16 @@
1
1
  const RESERVED_PANE_CHROME_ROWS = 2;
2
+ const FOCUSED_PANE_BODY_INSET = 1;
3
+ const RESERVED_FOCUSED_PANE_CHROME_COLUMNS = FOCUSED_PANE_BODY_INSET * 2;
2
4
 
3
5
  export function getPaneBodyHeight(height: number): number {
4
6
  // Reserve the header row plus the bottom border/resize row.
5
7
  return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
6
8
  }
9
+
10
+ export function getPaneBodyHorizontalInset(focused: boolean): number {
11
+ return focused ? FOCUSED_PANE_BODY_INSET : 0;
12
+ }
13
+
14
+ export function getPaneBodyWidth(width: number, focused: boolean): number {
15
+ return Math.max(1, width - (focused ? RESERVED_FOCUSED_PANE_CHROME_COLUMNS : 0));
16
+ }
@@ -1,7 +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
+ import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
5
5
 
6
6
  interface PaneWrapperProps {
7
7
  title?: string;
@@ -30,6 +30,7 @@ export function PaneWrapper({
30
30
  const bodyHeight = typeof height === "number"
31
31
  ? title ? getPaneBodyHeight(height) : height
32
32
  : undefined;
33
+ const bodyInset = getPaneBodyHorizontalInset(focused);
33
34
 
34
35
  return (
35
36
  <box
@@ -45,7 +46,13 @@ export function PaneWrapper({
45
46
  {title && (
46
47
  <PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
47
48
  )}
48
- <box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
49
+ <box
50
+ height={bodyHeight}
51
+ flexGrow={bodyHeight == null ? 1 : 0}
52
+ overflow="hidden"
53
+ paddingLeft={bodyInset}
54
+ paddingRight={bodyInset}
55
+ >
49
56
  {children}
50
57
  </box>
51
58
  </box>
@@ -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,10 +29,13 @@ 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
 
31
37
  function createShellPluginRegistry(options?: {
38
+ portfolioListComponent?: (props: PaneProps) => ReactNode;
32
39
  tickerDetailComponent?: (props: PaneProps) => ReactNode;
33
40
  }): PluginRegistry {
34
41
  return {
@@ -36,7 +43,7 @@ function createShellPluginRegistry(options?: {
36
43
  ["portfolio-list", {
37
44
  id: "portfolio-list",
38
45
  name: "Portfolio List",
39
- component: () => <text>Portfolio Body</text>,
46
+ component: options?.portfolioListComponent ?? (() => <text>Portfolio Body</text>),
40
47
  defaultPosition: "left",
41
48
  }],
42
49
  ["ticker-detail", {
@@ -134,31 +141,42 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
134
141
  };
135
142
  }
136
143
 
137
- function createHeaderDataProvider(): DataProvider {
144
+ function makePriceHistory(
145
+ length: number,
146
+ startDate = new Date(Date.UTC(2025, 0, 1)),
147
+ ): PricePoint[] {
148
+ return Array.from({ length }, (_, index) => ({
149
+ date: new Date(startDate.getTime() + index * 24 * 3600_000),
150
+ open: 100 + index * 0.2,
151
+ high: 101 + index * 0.2,
152
+ low: 99 + index * 0.2,
153
+ close: 100.5 + index * 0.2,
154
+ volume: 1_000 + index * 10,
155
+ }));
156
+ }
157
+
158
+ function createChartShellDataProvider(historyBySymbol: Record<string, PricePoint[]>): DataProvider {
138
159
  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
- },
160
+ id: "shell-test-provider",
161
+ name: "Shell Test Provider",
162
+ getTickerFinancials: async (symbol) => ({
163
+ annualStatements: [],
164
+ quarterlyStatements: [],
165
+ priceHistory: historyBySymbol[symbol] ?? [],
166
+ }),
167
+ getQuote: async (symbol) => ({
168
+ symbol,
169
+ price: historyBySymbol[symbol]?.[historyBySymbol[symbol]!.length - 1]?.close ?? 0,
170
+ currency: "USD",
171
+ change: 0,
172
+ changePercent: 0,
173
+ lastUpdated: Date.now(),
174
+ }),
175
+ getExchangeRate: async () => 1,
176
+ search: async () => [],
177
+ getNews: async () => [],
178
+ getArticleSummary: async () => null,
179
+ getPriceHistory: async (symbol) => historyBySymbol[symbol] ?? [],
162
180
  };
163
181
  }
164
182
 
@@ -256,7 +274,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
256
274
  return (
257
275
  <AppContext value={{ state, dispatch }}>
258
276
  <box flexDirection="column">
259
- <Header dataProvider={createHeaderDataProvider()} />
277
+ <Header />
260
278
  <Shell pluginRegistry={pluginRegistry} />
261
279
  <StatusBar />
262
280
  </box>
@@ -264,6 +282,50 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
264
282
  );
265
283
  }
266
284
 
285
+ function FocusBorderProbe({ width, height }: Pick<PaneProps, "width" | "height">) {
286
+ return (
287
+ <box flexDirection="column" width={width} height={height}>
288
+ <box height={1} width={width}>
289
+ <text>Read Probe</text>
290
+ </box>
291
+ <box height={1} width={width} flexDirection="row">
292
+ <box flexGrow={1} />
293
+ <text>Right</text>
294
+ </box>
295
+ </box>
296
+ );
297
+ }
298
+
299
+ function ChartShellHarness({
300
+ pluginRegistry,
301
+ config,
302
+ ticker,
303
+ financials,
304
+ focusedPaneId,
305
+ }: {
306
+ pluginRegistry: PluginRegistry;
307
+ config: ReturnType<typeof createDefaultConfig>;
308
+ ticker: TickerRecord;
309
+ financials: TickerFinancials;
310
+ focusedPaneId: string | null;
311
+ }) {
312
+ const initialState = createInitialState(config);
313
+ initialState.focusedPaneId = focusedPaneId;
314
+ initialState.tickers = new Map([[ticker.metadata.ticker, ticker]]);
315
+ initialState.financials = new Map([[ticker.metadata.ticker, financials]]);
316
+
317
+ const [state, dispatch] = useReducer(appReducer, initialState);
318
+ harnessState = state;
319
+
320
+ return (
321
+ <AppContext value={{ state, dispatch }}>
322
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
323
+ <Shell pluginRegistry={pluginRegistry} />
324
+ </DialogProvider>
325
+ </AppContext>
326
+ );
327
+ }
328
+
267
329
  describe("Header", () => {
268
330
  test("shows automatic self-update status for standalone binaries", async () => {
269
331
  testSetup = await testRender(
@@ -524,13 +586,100 @@ describe("Shell", () => {
524
586
  expect(testSetup.captureCharFrame()).toContain("Settings");
525
587
  });
526
588
 
589
+ test("applies a chart resolution chip on the first click even when the chart pane is unfocused", async () => {
590
+ const symbol = "AAPL";
591
+ const config = createDefaultConfig("/tmp/gloomberb-shell-chart-test");
592
+ config.layout = cloneLayout(config.layout);
593
+ config.layouts = [{ name: "Default", layout: cloneLayout(config.layout) }];
594
+ config.layout.instances = config.layout.instances.map((instance) => (
595
+ instance.instanceId === "ticker-detail:main"
596
+ ? {
597
+ ...instance,
598
+ binding: { kind: "fixed" as const, symbol },
599
+ settings: {
600
+ ...(instance.settings ?? {}),
601
+ chartResolution: "auto",
602
+ chartRangePreset: "5Y",
603
+ },
604
+ }
605
+ : instance
606
+ ));
607
+
608
+ const ticker = makeTicker({ ticker: symbol, name: symbol });
609
+ const history = makePriceHistory(260);
610
+ const financials: TickerFinancials = {
611
+ annualStatements: [],
612
+ quarterlyStatements: [],
613
+ priceHistory: history,
614
+ };
615
+
616
+ const provider = createChartShellDataProvider({ [symbol]: history });
617
+ setSharedDataProviderForTests(provider);
618
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
619
+
620
+ const pluginRegistry = createShellPluginRegistry({
621
+ tickerDetailComponent: (props) => (
622
+ <StockChart width={props.width} height={props.height} focused={props.focused} />
623
+ ),
624
+ });
625
+
626
+ testSetup = await testRender(
627
+ <ChartShellHarness
628
+ pluginRegistry={pluginRegistry}
629
+ config={config}
630
+ ticker={ticker}
631
+ financials={financials}
632
+ focusedPaneId="portfolio-list:main"
633
+ />,
634
+ { width: 120, height: 32 },
635
+ );
636
+
637
+ await testSetup.renderOnce();
638
+ await testSetup.renderOnce();
639
+
640
+ const initialLines = testSetup.captureCharFrame().split("\n");
641
+ const resolutionRow = initialLines.findIndex((line) => line.includes("AUTO 1M 5M 15M"));
642
+ const targetResolution = "1W";
643
+ expect(resolutionRow).toBeGreaterThanOrEqual(0);
644
+ const resolutionSpansBefore = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
645
+ let resolutionCol = -1;
646
+ let spanColumn = 0;
647
+ for (const span of resolutionSpansBefore) {
648
+ const chipOffset = span.text.indexOf(targetResolution);
649
+ if (chipOffset >= 0) {
650
+ resolutionCol = spanColumn + chipOffset + Math.floor(targetResolution.length / 2);
651
+ break;
652
+ }
653
+ spanColumn += span.width;
654
+ }
655
+ expect(resolutionCol).toBeGreaterThanOrEqual(0);
656
+ expect(testSetup.captureCharFrame()).toContain("AAPL - AUTO");
657
+
658
+ await act(async () => {
659
+ await testSetup!.mockMouse.click(resolutionCol, resolutionRow);
660
+ await testSetup!.renderOnce();
661
+ });
662
+ await testSetup.renderOnce();
663
+ await testSetup.renderOnce();
664
+
665
+ expect(
666
+ harnessState?.config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main")?.settings?.chartResolution,
667
+ ).toBe("1wk");
668
+ const resolutionSpans = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
669
+ const boldResolutionText = resolutionSpans
670
+ .filter((span) => (span.attributes & TextAttributes.BOLD) !== 0)
671
+ .map((span) => span.text)
672
+ .join("");
673
+ expect(boldResolutionText).toContain(targetResolution);
674
+ });
675
+
527
676
  test("keeps the cash drawer and gridlock tip on distinct click rows in the full app layout", async () => {
528
677
  const pluginRegistry = createBrokerPortfolioRegistry();
529
678
  const layoutUpdates: unknown[] = [];
530
679
  const toasts: string[] = [];
531
680
  pluginRegistry.getLayoutFn = () => harnessState?.config.layout ?? { dockRoot: null, instances: [], floating: [] };
532
681
  pluginRegistry.updateLayoutFn = (layout) => { layoutUpdates.push(layout); };
533
- pluginRegistry.showToastFn = (message: string) => { toasts.push(message); };
682
+ pluginRegistry.notify = ({ body }: { body: string }) => { toasts.push(body); };
534
683
  setSharedRegistryForTests(pluginRegistry);
535
684
 
536
685
  testSetup = await testRender(
@@ -568,6 +717,84 @@ describe("Shell", () => {
568
717
  expect(toasts).toEqual(["Retiled all panes"]);
569
718
  });
570
719
 
720
+ test("keeps focused docked pane body text inside the focus border", async () => {
721
+ const config = createDefaultConfig("/tmp/gloomberb-shell-focus-border-test");
722
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
723
+ if (!mainPane) throw new Error("missing default portfolio pane");
724
+
725
+ const singlePaneLayout = {
726
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
727
+ instances: [{ ...mainPane }],
728
+ floating: [],
729
+ };
730
+ const state = {
731
+ ...createInitialState({
732
+ ...config,
733
+ layout: cloneLayout(singlePaneLayout),
734
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
735
+ }),
736
+ focusedPaneId: "portfolio-list:main",
737
+ };
738
+
739
+ testSetup = await testRender(
740
+ <AppContext value={{ state, dispatch: () => {} }}>
741
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
742
+ <Shell pluginRegistry={createShellPluginRegistry({
743
+ portfolioListComponent: FocusBorderProbe,
744
+ })} />
745
+ </DialogProvider>
746
+ </AppContext>,
747
+ { width: 40, height: 10 },
748
+ );
749
+
750
+ await testSetup.renderOnce();
751
+
752
+ const frame = testSetup.captureCharFrame();
753
+ expect(frame).toContain("│Read Probe");
754
+ expect(frame).toContain("Right│");
755
+ expect(frame).not.toContain("│ead Probe");
756
+ expect(frame).not.toContain("Righ│");
757
+ });
758
+
759
+ test("keeps focused floating pane body text inside the focus border", async () => {
760
+ const config = createDefaultConfig("/tmp/gloomberb-shell-floating-focus-border-test");
761
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
762
+ if (!detailPane) throw new Error("missing detail pane");
763
+
764
+ const floatingOnlyLayout = {
765
+ dockRoot: null,
766
+ instances: [{ ...detailPane }],
767
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
768
+ };
769
+ const state = {
770
+ ...createInitialState({
771
+ ...config,
772
+ layout: cloneLayout(floatingOnlyLayout),
773
+ layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
774
+ }),
775
+ focusedPaneId: "ticker-detail:main",
776
+ };
777
+
778
+ testSetup = await testRender(
779
+ <AppContext value={{ state, dispatch: () => {} }}>
780
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
781
+ <Shell pluginRegistry={createShellPluginRegistry({
782
+ tickerDetailComponent: FocusBorderProbe,
783
+ })} />
784
+ </DialogProvider>
785
+ </AppContext>,
786
+ { width: 40, height: 12 },
787
+ );
788
+
789
+ await testSetup.renderOnce();
790
+
791
+ const frame = testSetup.captureCharFrame();
792
+ expect(frame).toContain("│Read Probe");
793
+ expect(frame).toContain("Right│");
794
+ expect(frame).not.toContain("│ead Probe");
795
+ expect(frame).not.toContain("Righ│");
796
+ });
797
+
571
798
  test("shows the gridlock tip after snapping a pane to a half screen", () => {
572
799
  const config = createDefaultConfig("/tmp/gloomberb-shell-snap-test");
573
800
  const snapLayout = cloneLayout(config.layout);
@@ -676,6 +903,33 @@ describe("Shell", () => {
676
903
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
677
904
  });
678
905
 
906
+ test("keeps a floating pane at the preview rect after a free drag release", () => {
907
+ const config = createDefaultConfig("/tmp/gloomberb-shell-drag-test");
908
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
909
+ if (!detailPane) throw new Error("missing detail pane");
910
+ const floatingOnlyLayout = {
911
+ dockRoot: null,
912
+ instances: [{ ...detailPane, binding: { kind: "fixed" as const, symbol: "AAPL" } }],
913
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
914
+ };
915
+
916
+ const result = finalizePaneDragRelease(
917
+ floatingOnlyLayout,
918
+ "ticker-detail:main",
919
+ { x: 14, y: 4, width: 30, height: 8 },
920
+ null,
921
+ );
922
+
923
+ expect(result.shouldShowGridlockTip).toBe(false);
924
+ expect(result.nextLayout.floating[0]).toEqual(expect.objectContaining({
925
+ instanceId: "ticker-detail:main",
926
+ x: 14,
927
+ y: 4,
928
+ width: 30,
929
+ height: 8,
930
+ }));
931
+ });
932
+
679
933
  test("keeps the last floating pane body row visible above the border", async () => {
680
934
  const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
681
935
  const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");