gloomberb 0.4.0 → 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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  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 +296 -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 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  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 +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  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 +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  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 +33 -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/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. 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,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { colors, floatingPaneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
+ import { getPaneBodyHeight } from "./pane-sizing";
4
5
 
5
6
  interface FloatingPaneWrapperProps {
6
7
  title: string;
@@ -20,6 +21,7 @@ export function FloatingPaneWrapper({
20
21
  title, x, y, width, height, zIndex, focused, showActions = false, onMouseMove, children,
21
22
  }: FloatingPaneWrapperProps) {
22
23
  const bg = floatingPaneBg(focused);
24
+ const bodyHeight = getPaneBodyHeight(height);
23
25
 
24
26
  return (
25
27
  <box
@@ -36,7 +38,7 @@ export function FloatingPaneWrapper({
36
38
  <PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
37
39
 
38
40
  {/* Content */}
39
- <box flexGrow={1} overflow="hidden">
41
+ <box height={bodyHeight} overflow="hidden">
40
42
  {children}
41
43
  </box>
42
44
 
@@ -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
 
@@ -31,7 +32,7 @@ function UpdateStatus() {
31
32
  <box flexDirection="row" gap={1}>
32
33
  <spinner name="dots" color={colors.headerText} />
33
34
  <text fg={colors.headerText}>
34
- Downloading v{updateAvailable?.version}... {updateProgress.percent ?? 0}%
35
+ Downloading v{updateAvailable?.version}: {updateProgress.percent ?? 0}%
35
36
  </text>
36
37
  </box>
37
38
  );
@@ -45,7 +46,7 @@ function UpdateStatus() {
45
46
  );
46
47
  }
47
48
  if (updateProgress.phase === "done") {
48
- return <text fg={colors.headerText}>Update installed — restart to apply</text>;
49
+ return <text fg={colors.headerText}>Update installed, restart to apply</text>;
49
50
  }
50
51
  if (updateProgress.phase === "error") {
51
52
  return <text fg={colors.headerText}>Update failed: {updateProgress.error}</text>;
@@ -71,7 +72,7 @@ function UpdateStatus() {
71
72
  }
72
73
  return (
73
74
  <text fg={colors.headerText}>
74
- v{updateAvailable.version} available — press <span fg={colors.headerText}>u</span> to update
75
+ v{updateAvailable.version} available — starting download...
75
76
  </text>
76
77
  );
77
78
  }
@@ -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
@@ -0,0 +1,6 @@
1
+ const RESERVED_PANE_CHROME_ROWS = 2;
2
+
3
+ export function getPaneBodyHeight(height: number): number {
4
+ // Reserve the header row plus the bottom border/resize row.
5
+ return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
6
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { paneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
+ import { getPaneBodyHeight } from "./pane-sizing";
4
5
 
5
6
  interface PaneWrapperProps {
6
7
  title?: string;
@@ -26,6 +27,9 @@ export function PaneWrapper({
26
27
  children,
27
28
  }: PaneWrapperProps) {
28
29
  const bg = paneBg(focused);
30
+ const bodyHeight = typeof height === "number"
31
+ ? title ? getPaneBodyHeight(height) : height
32
+ : undefined;
29
33
 
30
34
  return (
31
35
  <box
@@ -41,7 +45,7 @@ export function PaneWrapper({
41
45
  {title && (
42
46
  <PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
43
47
  )}
44
- <box flexGrow={1} overflow="hidden">
48
+ <box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
45
49
  {children}
46
50
  </box>
47
51
  </box>
@@ -1,17 +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";
5
+ import type { ReactNode } from "react";
4
6
  import { act, useReducer } from "react";
5
7
  import { AppContext, appReducer, createInitialState } from "../../state/app-context";
8
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
9
+ import { setSharedDataProviderForTests } from "../../plugins/registry";
6
10
  import { cloneLayout, createDefaultConfig } from "../../types/config";
7
11
  import type { PluginRegistry } from "../../plugins/registry";
8
12
  import { setSharedRegistryForTests } from "../../plugins/registry";
9
13
  import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
14
+ import type { PaneProps } from "../../types/plugin";
15
+ import { StockChart } from "../chart/stock-chart";
10
16
  import { StatusBar } from "./status-bar";
11
17
  import { Header } from "./header";
12
18
  import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
13
19
  import type { DataProvider } from "../../types/data-provider";
14
- import type { Quote } from "../../types/financials";
20
+ import type { PricePoint, Quote, TickerFinancials } from "../../types/financials";
15
21
  import type { TickerRecord } from "../../types/ticker";
16
22
 
17
23
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
@@ -23,10 +29,14 @@ afterEach(() => {
23
29
  testSetup = undefined;
24
30
  }
25
31
  harnessState = null;
32
+ setSharedMarketDataCoordinator(null);
33
+ setSharedDataProviderForTests(undefined);
26
34
  setSharedRegistryForTests(undefined);
27
35
  });
28
36
 
29
- function createShellPluginRegistry(): PluginRegistry {
37
+ function createShellPluginRegistry(options?: {
38
+ tickerDetailComponent?: (props: PaneProps) => ReactNode;
39
+ }): PluginRegistry {
30
40
  return {
31
41
  panes: new Map([
32
42
  ["portfolio-list", {
@@ -38,7 +48,7 @@ function createShellPluginRegistry(): PluginRegistry {
38
48
  ["ticker-detail", {
39
49
  id: "ticker-detail",
40
50
  name: "Ticker Detail",
41
- component: () => <text>Ticker Detail Body</text>,
51
+ component: options?.tickerDetailComponent ?? (() => <text>Ticker Detail Body</text>),
42
52
  defaultPosition: "right",
43
53
  defaultMode: "floating",
44
54
  }],
@@ -130,45 +140,59 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
130
140
  };
131
141
  }
132
142
 
133
- 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 {
134
158
  return {
135
- id: "test",
136
- name: "Test",
137
- async getTickerFinancials() {
138
- return { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote({ symbol: "SPY", name: "SPY" }) };
139
- },
140
- async getQuote() {
141
- return makeQuote({ symbol: "SPY", name: "SPY" });
142
- },
143
- async getExchangeRate() {
144
- return 1;
145
- },
146
- async search() {
147
- return [];
148
- },
149
- async getNews() {
150
- return [];
151
- },
152
- async getArticleSummary() {
153
- return null;
154
- },
155
- async getPriceHistory() {
156
- return [];
157
- },
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] ?? [],
158
179
  };
159
180
  }
160
181
 
161
182
  function HeaderHarness({
162
183
  updateAvailable = null,
184
+ updateProgress = null,
163
185
  updateCheckInProgress = false,
164
186
  updateNotice = null,
165
187
  }: {
166
188
  updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
189
+ updateProgress?: ReturnType<typeof createInitialState>["updateProgress"];
167
190
  updateCheckInProgress?: boolean;
168
191
  updateNotice?: string | null;
169
192
  }) {
170
193
  const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
171
194
  initialState.updateAvailable = updateAvailable;
195
+ initialState.updateProgress = updateProgress;
172
196
  initialState.updateCheckInProgress = updateCheckInProgress;
173
197
  initialState.updateNotice = updateNotice;
174
198
  const [state, dispatch] = useReducer(appReducer, initialState);
@@ -249,7 +273,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
249
273
  return (
250
274
  <AppContext value={{ state, dispatch }}>
251
275
  <box flexDirection="column">
252
- <Header dataProvider={createHeaderDataProvider()} />
276
+ <Header />
253
277
  <Shell pluginRegistry={pluginRegistry} />
254
278
  <StatusBar />
255
279
  </box>
@@ -257,8 +281,38 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
257
281
  );
258
282
  }
259
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
+
260
314
  describe("Header", () => {
261
- test("shows the self-update shortcut for standalone binaries", async () => {
315
+ test("shows automatic self-update status for standalone binaries", async () => {
262
316
  testSetup = await testRender(
263
317
  <HeaderHarness updateAvailable={{
264
318
  version: "0.3.0",
@@ -274,7 +328,8 @@ describe("Header", () => {
274
328
  const frame = testSetup.captureCharFrame();
275
329
 
276
330
  expect(frame).toContain("v0.3.0 available");
277
- expect(frame).toContain("press u to update");
331
+ expect(frame).toContain("starting download");
332
+ expect(frame).not.toContain("press u to update");
278
333
  });
279
334
 
280
335
  test("shows the manual npm command when self-update is disabled", async () => {
@@ -297,7 +352,26 @@ describe("Header", () => {
297
352
  expect(frame).not.toContain("press u to update");
298
353
  });
299
354
 
300
- test("shows update check progress and notices", async () => {
355
+ test("shows update download progress and notices", async () => {
356
+ testSetup = await testRender(
357
+ <HeaderHarness
358
+ updateAvailable={{
359
+ version: "0.3.0",
360
+ tagName: "v0.3.0",
361
+ downloadUrl: "https://example.com/gloomberb",
362
+ publishedAt: "2026-04-01T00:00:00.000Z",
363
+ updateAction: { kind: "self" },
364
+ }}
365
+ updateProgress={{ phase: "downloading", percent: 42 }}
366
+ />,
367
+ { width: 120, height: 2 },
368
+ );
369
+
370
+ await testSetup.renderOnce();
371
+ expect(testSetup.captureCharFrame()).toContain("Downloading v0.3.0: 42%");
372
+
373
+ testSetup.renderer.destroy();
374
+
301
375
  testSetup = await testRender(
302
376
  <HeaderHarness updateCheckInProgress />,
303
377
  { width: 120, height: 2 },
@@ -497,13 +571,98 @@ describe("Shell", () => {
497
571
  expect(testSetup.captureCharFrame()).toContain("Settings");
498
572
  });
499
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
+
500
659
  test("keeps the cash drawer and gridlock tip on distinct click rows in the full app layout", async () => {
501
660
  const pluginRegistry = createBrokerPortfolioRegistry();
502
661
  const layoutUpdates: unknown[] = [];
503
662
  const toasts: string[] = [];
504
663
  pluginRegistry.getLayoutFn = () => harnessState?.config.layout ?? { dockRoot: null, instances: [], floating: [] };
505
664
  pluginRegistry.updateLayoutFn = (layout) => { layoutUpdates.push(layout); };
506
- pluginRegistry.showToastFn = (message: string) => { toasts.push(message); };
665
+ pluginRegistry.notify = ({ body }: { body: string }) => { toasts.push(body); };
507
666
  setSharedRegistryForTests(pluginRegistry);
508
667
 
509
668
  testSetup = await testRender(
@@ -648,4 +807,76 @@ describe("Shell", () => {
648
807
  expect(updateLayout?.layout.floating).toEqual([]);
649
808
  expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
650
809
  });
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
+
838
+ test("keeps the last floating pane body row visible above the border", async () => {
839
+ const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
840
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
841
+ if (!detailPane) throw new Error("missing detail pane");
842
+
843
+ const floatingOnlyLayout = {
844
+ dockRoot: null,
845
+ instances: [{ ...detailPane }],
846
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
847
+ };
848
+ const state = {
849
+ ...createInitialState({
850
+ ...config,
851
+ layout: cloneLayout(floatingOnlyLayout),
852
+ layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
853
+ }),
854
+ focusedPaneId: "ticker-detail:main",
855
+ };
856
+
857
+ testSetup = await testRender(
858
+ <AppContext value={{ state, dispatch: () => {} }}>
859
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
860
+ <Shell pluginRegistry={createShellPluginRegistry({
861
+ tickerDetailComponent: ({ width, height }) => (
862
+ <box flexDirection="column" width={width} height={height}>
863
+ <box flexGrow={1} />
864
+ <box paddingLeft={1}>
865
+ <text>Footer Probe</text>
866
+ </box>
867
+ </box>
868
+ ),
869
+ })} />
870
+ </DialogProvider>
871
+ </AppContext>,
872
+ { width: 40, height: 12 },
873
+ );
874
+
875
+ await act(async () => {
876
+ await testSetup!.renderOnce();
877
+ await testSetup!.renderOnce();
878
+ });
879
+
880
+ expect(testSetup.captureCharFrame()).toContain("Footer Probe");
881
+ });
651
882
  });