gloomberb 0.9.2 → 0.9.4

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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -12,6 +12,7 @@ import {
12
12
  type ComparisonChartRenderMode,
13
13
  type TimeRange,
14
14
  } from "../core/types";
15
+ import type { MouseInteractionEvent } from "../core/pointer";
15
16
 
16
17
  const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
17
18
  area: "A",
@@ -22,6 +23,7 @@ interface ComparisonChartToolbarProps {
22
23
  activePreset: TimeRange | null;
23
24
  availableManualResolutions: ManualChartResolution[];
24
25
  effectiveResolution: ChartResolution;
26
+ focusPaneForMouseInteraction: (event: MouseInteractionEvent | null | undefined) => void;
25
27
  isUpdating: boolean;
26
28
  onRangeSelect: (range: TimeRange) => void;
27
29
  onRenderModeSelect: (mode: ComparisonChartRenderMode) => void;
@@ -36,6 +38,7 @@ export function ComparisonChartToolbar({
36
38
  activePreset,
37
39
  availableManualResolutions,
38
40
  effectiveResolution,
41
+ focusPaneForMouseInteraction,
39
42
  isUpdating,
40
43
  onRangeSelect,
41
44
  onRenderModeSelect,
@@ -54,7 +57,8 @@ export function ComparisonChartToolbar({
54
57
  key={range}
55
58
  fg={activePreset === range ? colors.textBright : (isRangePresetSupported(range, availableManualResolutions) ? colors.textDim : colors.textMuted)}
56
59
  attributes={activePreset === range ? TextAttributes.BOLD : 0}
57
- onMouseDown={() => {
60
+ onMouseDown={(event: any) => {
61
+ focusPaneForMouseInteraction(event);
58
62
  if (isRangePresetSupported(range, availableManualResolutions)) {
59
63
  onRangeSelect(range);
60
64
  }
@@ -73,7 +77,10 @@ export function ComparisonChartToolbar({
73
77
  key={resolution}
74
78
  fg={effectiveResolution === resolution ? colors.textBright : colors.textDim}
75
79
  attributes={effectiveResolution === resolution ? TextAttributes.BOLD : 0}
76
- onMouseDown={() => onResolutionSelect(resolution)}
80
+ onMouseDown={(event: any) => {
81
+ focusPaneForMouseInteraction(event);
82
+ onResolutionSelect(resolution);
83
+ }}
77
84
  >
78
85
  {getChartResolutionLabel(resolution)}
79
86
  </Text>
@@ -89,7 +96,10 @@ export function ComparisonChartToolbar({
89
96
  key={mode}
90
97
  fg={renderMode === mode ? colors.textBright : colors.textDim}
91
98
  attributes={renderMode === mode ? TextAttributes.BOLD : 0}
92
- onMouseDown={() => onRenderModeSelect(mode)}
99
+ onMouseDown={(event: any) => {
100
+ focusPaneForMouseInteraction(event);
101
+ onRenderModeSelect(mode);
102
+ }}
93
103
  >
94
104
  {MODE_CHIPS[mode]}
95
105
  </Text>
@@ -270,6 +270,7 @@ function ComparisonStockChartView({
270
270
  isUpdating,
271
271
  resolutionChips,
272
272
  selectionSupportMap,
273
+ performanceHistoryBySymbol,
273
274
  series,
274
275
  supportMap,
275
276
  } = useComparisonChartRenderData({
@@ -279,7 +280,11 @@ function ComparisonStockChartView({
279
280
  const performanceBySymbol = useMemo(() => {
280
281
  const bySymbol = new Map<string, PriceReturnField[]>();
281
282
  for (const source of symbolSources) {
282
- const sourceHistory = appendQuoteToPriceReturnHistory(source.priceHistory, source.quote);
283
+ const baselineHistory = performanceHistoryBySymbol.get(source.symbol) ?? [];
284
+ const sourceHistory = appendQuoteToPriceReturnHistory(
285
+ baselineHistory.length >= 2 ? baselineHistory : source.priceHistory,
286
+ source.quote,
287
+ );
283
288
  const fallbackHistory = series.find((entry) => entry.symbol === source.symbol)?.points ?? [];
284
289
  bySymbol.set(
285
290
  source.symbol,
@@ -287,7 +292,7 @@ function ComparisonStockChartView({
287
292
  );
288
293
  }
289
294
  return bySymbol;
290
- }, [series, symbolSources]);
295
+ }, [performanceHistoryBySymbol, series, symbolSources]);
291
296
  const summarySymbols = useMemo(
292
297
  () => sortSymbolsByOneYearReturn(symbols, performanceBySymbol),
293
298
  [performanceBySymbol, symbols],
@@ -340,6 +345,7 @@ function ComparisonStockChartView({
340
345
  displayCursor,
341
346
  displayCursorX,
342
347
  displayCursorY,
348
+ focusPaneForMouseInteraction,
343
349
  mouseCrosshairDisabledRef,
344
350
  plotRef,
345
351
  scrollPanCellRemainderRef,
@@ -351,6 +357,7 @@ function ComparisonStockChartView({
351
357
  chartWidth,
352
358
  cursorX: viewState.cursorX,
353
359
  cursorY: viewState.cursorY,
360
+ focused,
354
361
  renderer,
355
362
  setViewState,
356
363
  symbols: summarySymbols,
@@ -444,6 +451,7 @@ function ComparisonStockChartView({
444
451
  chartWidth,
445
452
  commitSelectionCursor,
446
453
  expandBufferRange,
454
+ focusPaneForMouseInteraction,
447
455
  mouseCrosshairDisabledRef,
448
456
  plotRef,
449
457
  pointCount: projection.dates.length,
@@ -522,6 +530,7 @@ function ComparisonStockChartView({
522
530
  legendActiveIndex={legendActiveIndex}
523
531
  legendItemWidth={legendItemWidth}
524
532
  legendRows={legendRows}
533
+ onFocusInteraction={focusPaneForMouseInteraction}
525
534
  onOpenSymbol={onOpenSymbol}
526
535
  onSelectSymbol={setSelectedSymbol}
527
536
  performanceBySymbol={performanceBySymbol}
@@ -542,6 +551,7 @@ function ComparisonStockChartView({
542
551
  activePreset={activePreset}
543
552
  availableManualResolutions={availableManualResolutions}
544
553
  effectiveResolution={effectiveResolution}
554
+ focusPaneForMouseInteraction={focusPaneForMouseInteraction}
545
555
  isUpdating={isUpdating}
546
556
  onRangeSelect={setRangePreset}
547
557
  onRenderModeSelect={(mode) => setViewState((current) => ({ ...current, renderMode: mode }))}
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createDefaultConfig,
4
+ findPaneInstance,
5
+ } from "../../../types/config";
2
6
  import {
3
7
  consumeStoredChartSelectionChange,
8
+ createChartRenderModeConfig,
4
9
  createStoredChartSelectionSyncState,
5
10
  markStoredChartSelectionLocallyApplied,
6
11
  } from "./pane-settings";
@@ -22,4 +27,26 @@ describe("chart pane setting sync", () => {
22
27
  expect(consumeStoredChartSelectionChange(state, "5Y", "1wk")).toBe(true);
23
28
  expect(state.lastAppliedKey).toBe("5Y:1wk");
24
29
  });
30
+
31
+ test("persists chart render mode as both pane state and global default", () => {
32
+ const config = createDefaultConfig("/tmp/gloomberb-chart-mode-test");
33
+
34
+ const nextConfig = createChartRenderModeConfig({
35
+ activePanel: "right",
36
+ config,
37
+ focusedPaneId: "ticker-detail:main",
38
+ paneState: {},
39
+ }, "ticker-detail:main", "candles");
40
+
41
+ expect(nextConfig.chartPreferences.defaultRenderMode).toBe("candles");
42
+ expect(findPaneInstance(nextConfig.layout, "ticker-detail:main")?.settings).toMatchObject({
43
+ chartRangePreset: "5Y",
44
+ chartRenderMode: "candles",
45
+ chartResolution: "auto",
46
+ });
47
+ expect(findPaneInstance(
48
+ nextConfig.layouts[nextConfig.activeLayoutIndex]!.layout,
49
+ "ticker-detail:main",
50
+ )?.settings?.chartRenderMode).toBe("candles");
51
+ });
25
52
  });
@@ -7,7 +7,18 @@ import {
7
7
  syncConfigActiveLayoutState,
8
8
  } from "../../../state/app/context";
9
9
  import { scheduleConfigSave } from "../../../state/config-save-scheduler";
10
- import type { ChartResolution, TimeRange } from "./types";
10
+ import {
11
+ findPaneInstance,
12
+ type AppConfig,
13
+ } from "../../../types/config";
14
+ import type { ChartRenderMode, ChartResolution, TimeRange } from "./types";
15
+
16
+ interface ChartRenderModePersistenceState {
17
+ activePanel: "left" | "right";
18
+ config: AppConfig;
19
+ focusedPaneId: string | null;
20
+ paneState: Record<string, Record<string, unknown>>;
21
+ }
11
22
 
12
23
  export interface StoredChartSelectionSyncState {
13
24
  lastAppliedKey: string;
@@ -80,3 +91,51 @@ export function usePersistChartControlSelection(rangePresetKey: string): (
80
91
  scheduleConfigSave(nextConfig);
81
92
  };
82
93
  }
94
+
95
+ export function createChartRenderModeConfig(
96
+ currentState: ChartRenderModePersistenceState,
97
+ paneId: string,
98
+ renderMode: ChartRenderMode,
99
+ ): AppConfig {
100
+ const currentPane = findPaneInstance(currentState.config.layout, paneId);
101
+ const layout = currentPane
102
+ ? setPaneSettings(currentState.config.layout, paneId, {
103
+ ...(currentPane.settings ?? {}),
104
+ chartRenderMode: renderMode,
105
+ })
106
+ : currentState.config.layout;
107
+ return syncConfigActiveLayoutState(
108
+ {
109
+ ...currentState.config,
110
+ chartPreferences: {
111
+ ...currentState.config.chartPreferences,
112
+ defaultRenderMode: renderMode,
113
+ },
114
+ layout,
115
+ },
116
+ currentState.paneState,
117
+ currentState.focusedPaneId,
118
+ currentState.activePanel,
119
+ );
120
+ }
121
+
122
+ export function usePersistChartRenderMode(): (renderMode: ChartRenderMode) => void {
123
+ const dispatch = useAppDispatch();
124
+ const stateRef = useAppStateRef();
125
+ const paneId = usePaneInstanceId();
126
+
127
+ return (renderMode) => {
128
+ const currentState = stateRef.current;
129
+ const currentPane = findPaneInstance(currentState.config.layout, paneId);
130
+ if (
131
+ currentState.config.chartPreferences.defaultRenderMode === renderMode
132
+ && currentPane?.settings?.chartRenderMode === renderMode
133
+ ) {
134
+ return;
135
+ }
136
+
137
+ const nextConfig = createChartRenderModeConfig(currentState, paneId, renderMode);
138
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
139
+ scheduleConfigSave(nextConfig);
140
+ };
141
+ }
@@ -11,11 +11,16 @@ interface RenderableSizeLike {
11
11
  interface PlotRenderableLike extends RenderableSizeLike {
12
12
  x?: number;
13
13
  y?: number;
14
+ absoluteX?: number;
15
+ absoluteY?: number;
16
+ absoluteBounds?: { x: number; y: number; width: number; height: number };
14
17
  }
15
18
 
16
19
  export interface ChartMouseEvent {
17
20
  x: number;
18
21
  y: number;
22
+ preciseX?: number;
23
+ preciseY?: number;
19
24
  pixelX?: number;
20
25
  pixelY?: number;
21
26
  stopPropagation?: () => void;
@@ -31,6 +36,8 @@ export interface ChartMouseEvent {
31
36
  };
32
37
  }
33
38
 
39
+ export type MouseInteractionEvent = Pick<ChartMouseEvent, "stopPropagation" | "preventDefault">;
40
+
34
41
  export function consumeChartMouseEvent(event: Pick<ChartMouseEvent, "stopPropagation" | "preventDefault">): void {
35
42
  event.stopPropagation?.();
36
43
  event.preventDefault?.();
@@ -93,14 +100,33 @@ function getRendererCellMetrics(renderer: RendererMetricsHost) {
93
100
  }
94
101
 
95
102
  function getRenderableSize(renderable: RenderableSizeLike | null): { width: number; height: number } | null {
96
- if (!renderable || typeof renderable.width !== "number" || typeof renderable.height !== "number") return null;
97
- return { width: renderable.width, height: renderable.height };
103
+ if (!renderable) return null;
104
+ if (typeof renderable.width === "number" && typeof renderable.height === "number") {
105
+ return { width: renderable.width, height: renderable.height };
106
+ }
107
+ const absoluteBounds = (renderable as PlotRenderableLike).absoluteBounds;
108
+ if (absoluteBounds && typeof absoluteBounds.width === "number" && typeof absoluteBounds.height === "number") {
109
+ return { width: absoluteBounds.width, height: absoluteBounds.height };
110
+ }
111
+ return null;
98
112
  }
99
113
 
100
114
  function getPlotBounds(renderable: PlotRenderableLike | null): { x: number; y: number; width: number; height: number } | null {
101
- if (!renderable || typeof renderable.x !== "number" || typeof renderable.y !== "number") return null;
115
+ if (!renderable) return null;
102
116
  const size = getRenderableSize(renderable);
103
- return size ? { x: renderable.x, y: renderable.y, width: size.width, height: size.height } : null;
117
+ if (!size) return null;
118
+ const x = typeof renderable.absoluteX === "number"
119
+ ? renderable.absoluteX
120
+ : typeof renderable.absoluteBounds?.x === "number"
121
+ ? renderable.absoluteBounds.x
122
+ : renderable.x;
123
+ const y = typeof renderable.absoluteY === "number"
124
+ ? renderable.absoluteY
125
+ : typeof renderable.absoluteBounds?.y === "number"
126
+ ? renderable.absoluteBounds.y
127
+ : renderable.y;
128
+ if (typeof x !== "number" || typeof y !== "number") return null;
129
+ return { x, y, width: size.width, height: size.height };
104
130
  }
105
131
 
106
132
  export function getRenderablePixelSize(
@@ -214,8 +240,10 @@ export function getLocalPlotPointer(
214
240
  const bounds = getPlotBounds(renderable);
215
241
  if (!bounds) return null;
216
242
 
217
- const localCellX = event.x - bounds.x;
218
- const localCellY = event.y - bounds.y;
243
+ const rawCellX = typeof event.preciseX === "number" ? event.preciseX : event.x;
244
+ const rawCellY = typeof event.preciseY === "number" ? event.preciseY : event.y;
245
+ const localCellX = rawCellX - bounds.x;
246
+ const localCellY = rawCellY - bounds.y;
219
247
  if (localCellX < 0 || localCellX >= bounds.width || localCellY < 0 || localCellY >= bounds.height) {
220
248
  return null;
221
249
  }
@@ -12,6 +12,7 @@ interface PriceAxisLabelsProps {
12
12
  cursorPixelY?: number | null;
13
13
  cursorLabel: string | null;
14
14
  cursorColor: string;
15
+ axisColor?: string;
15
16
  }
16
17
 
17
18
  interface CursorPriceAxisOverlay {
@@ -61,6 +62,7 @@ export function PriceAxisLabels({
61
62
  cursorPixelY = null,
62
63
  cursorLabel,
63
64
  cursorColor,
65
+ axisColor = colors.textDim,
64
66
  }: PriceAxisLabelsProps) {
65
67
  const { cellHeightPx = 18, fractionalViewport = false } = useUiCapabilities();
66
68
  const overlay = useMemo(() => buildCursorPriceAxisOverlay({
@@ -100,7 +102,7 @@ export function PriceAxisLabels({
100
102
  const label = isCursorRow ? cursorLabel : (axisLabels.get(row) ?? null);
101
103
  return (
102
104
  <Box key={row} height={1}>
103
- {renderAxisLabel(label, isCursorRow ? cursorColor : colors.textDim)}
105
+ {renderAxisLabel(label, isCursorRow ? cursorColor : axisColor)}
104
106
  </Box>
105
107
  );
106
108
  })}
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { Text } from "../../../ui";
4
+ import { testRender } from "../../../renderers/opentui/test-utils";
5
+ import { StaticBarChartSurface } from "./bar-chart-surface";
6
+
7
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
8
+
9
+ afterEach(async () => {
10
+ if (!testSetup) return;
11
+ await act(async () => {
12
+ testSetup!.renderer.destroy();
13
+ });
14
+ testSetup = undefined;
15
+ });
16
+
17
+ describe("StaticBarChartSurface", () => {
18
+ test("shows hover feedback without shifting chart rows", async () => {
19
+ testSetup = await testRender(
20
+ <StaticBarChartSurface
21
+ width={64}
22
+ height={12}
23
+ title="Operating Cash Flow (quarterly)"
24
+ formatValue={(value) => `${Math.round(value / 1000)}k`}
25
+ series={[{
26
+ id: "value",
27
+ label: "Value",
28
+ color: "#00ff66",
29
+ points: [
30
+ { category: "2025 Q1", value: -271000 },
31
+ { category: "2025 Q2", value: -138000 },
32
+ { category: "2025 Q3", value: 653000 },
33
+ ],
34
+ }]}
35
+ />,
36
+ { width: 66, height: 14 },
37
+ );
38
+
39
+ await act(async () => {
40
+ await testSetup!.renderOnce();
41
+ await testSetup!.renderOnce();
42
+ });
43
+
44
+ const initialFrame = testSetup.captureCharFrame();
45
+ const initialLines = initialFrame.split("\n");
46
+ const initialAxisRow = initialLines.findIndex((line) => line.includes("2025 Q1") && line.includes("2025 Q2"));
47
+
48
+ await act(async () => {
49
+ await testSetup!.mockMouse.moveTo(14, 4);
50
+ await testSetup!.renderOnce();
51
+ await testSetup!.renderOnce();
52
+ });
53
+
54
+ const hoverFrame = testSetup.captureCharFrame();
55
+ const hoverLines = hoverFrame.split("\n");
56
+ const hoverAxisRow = hoverLines.findIndex((line) => line.includes("2025 Q1") && line.includes("2025 Q2"));
57
+
58
+ expect(hoverFrame).toContain("2025 Q1: -271k");
59
+ expect(hoverFrame).toContain("▓");
60
+ expect(hoverLines).toHaveLength(initialLines.length);
61
+ expect(hoverAxisRow).toBe(initialAxisRow);
62
+ });
63
+
64
+ test("shows a hover readout when a custom header is rendered", async () => {
65
+ testSetup = await testRender(
66
+ <StaticBarChartSurface
67
+ width={64}
68
+ height={12}
69
+ header={<Text>Revenue Gross Profit</Text>}
70
+ formatValue={(value) => `${Math.round(value / 1000)}k`}
71
+ series={[{
72
+ id: "value",
73
+ label: "Value",
74
+ color: "#00ff66",
75
+ points: [
76
+ { category: "2025 Q1", value: -271000 },
77
+ { category: "2025 Q2", value: -138000 },
78
+ { category: "2025 Q3", value: 653000 },
79
+ ],
80
+ }]}
81
+ />,
82
+ { width: 66, height: 14 },
83
+ );
84
+
85
+ await act(async () => {
86
+ await testSetup!.renderOnce();
87
+ await testSetup!.renderOnce();
88
+ await testSetup!.mockMouse.moveTo(14, 4);
89
+ await testSetup!.renderOnce();
90
+ await testSetup!.renderOnce();
91
+ });
92
+
93
+ const frame = testSetup.captureCharFrame();
94
+ expect(frame).toContain("Revenue Gross Profit");
95
+ expect(frame).toContain("2025 Q1: -271k");
96
+ });
97
+ });
@@ -1,14 +1,19 @@
1
- import { useMemo } from "react";
2
- import { Box, ChartSurface, Span, Text } from "../../../ui";
1
+ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { Box, ChartSurface, Span, Text, useNativeRenderer, type BoxRenderable } from "../../../ui";
3
3
  import { colors } from "../../../theme/colors";
4
+ import { consumeChartMouseEvent, getLocalPlotPointer, type ChartMouseEvent } from "../core/pointer";
4
5
  import type { NativeChartBitmap } from "../native/chart-rasterizer";
6
+ import { truncateWithEllipsis } from "../../../utils/text-wrap";
5
7
  import { useStaticChartBitmapSize } from "./chart/bitmap";
6
8
  import {
7
9
  buildBarChartScene,
8
10
  renderBarChart,
9
11
  renderBarChartAxis,
12
+ renderBarChartYAxis,
10
13
  renderNativeBarChart,
14
+ resolveBarChartHover,
11
15
  type BarChartColors,
16
+ type BarChartHover,
12
17
  type BarChartSeries,
13
18
  } from "../bar-chart-renderer";
14
19
 
@@ -17,42 +22,116 @@ export interface StaticBarChartSurfaceProps {
17
22
  width: number;
18
23
  height: number;
19
24
  colors?: BarChartColors;
25
+ title?: string;
26
+ header?: ReactNode;
27
+ formatValue?: (value: number) => string;
28
+ onHoverChange?: (hover: BarChartHover | null) => void;
29
+ onMouseDown?: (event: ChartMouseEvent) => void;
20
30
  }
21
31
 
22
32
  export function StaticBarChartSurface({
23
33
  series,
24
34
  width,
25
35
  height,
26
- colors: chartColors = {
27
- bgColor: colors.bg,
28
- gridColor: colors.border,
29
- axisColor: colors.textDim,
30
- },
36
+ title,
37
+ header,
38
+ formatValue,
39
+ onHoverChange,
40
+ onMouseDown,
41
+ colors: chartColors,
31
42
  }: StaticBarChartSurfaceProps) {
43
+ const renderer = useNativeRenderer();
44
+ const plotRef = useRef<BoxRenderable | null>(null);
45
+ const [hover, setHover] = useState<BarChartHover | null>(null);
32
46
  const totalWidth = Math.max(1, Math.floor(width));
33
47
  const totalHeight = Math.max(4, Math.floor(height));
48
+ const headerRows = title || header ? 1 : 0;
34
49
  const legendRows = series.length > 1 ? 1 : 0;
35
50
  const axisRows = 1;
36
- const plotHeight = Math.max(2, totalHeight - legendRows - axisRows);
51
+ const yAxisWidth = totalWidth >= 24 ? 8 : 0;
52
+ const plotRightPadding = totalWidth >= 32 ? 1 : 0;
53
+ const plotWidth = Math.max(1, totalWidth - yAxisWidth - plotRightPadding);
54
+ const plotHeight = Math.max(2, totalHeight - headerRows - legendRows - axisRows);
55
+ const resolvedChartColors = useMemo<BarChartColors>(() => ({
56
+ bgColor: chartColors?.bgColor ?? colors.bg,
57
+ gridColor: chartColors?.gridColor ?? colors.border,
58
+ axisColor: chartColors?.axisColor ?? colors.textDim,
59
+ negativeColor: chartColors?.negativeColor ?? colors.negative,
60
+ hoverColor: chartColors?.hoverColor ?? colors.textBright,
61
+ }), [chartColors]);
37
62
  const scene = useMemo(() => buildBarChartScene(series, {
38
- width: totalWidth,
63
+ width: plotWidth,
39
64
  height: plotHeight,
40
- colors: chartColors,
41
- }), [chartColors, plotHeight, series, totalWidth]);
65
+ colors: resolvedChartColors,
66
+ }), [plotHeight, plotWidth, resolvedChartColors, series]);
42
67
 
43
- const bitmapSize = useStaticChartBitmapSize(totalWidth, plotHeight);
68
+ const bitmapSize = useStaticChartBitmapSize(plotWidth, plotHeight);
44
69
 
45
70
  const bitmap = useMemo<NativeChartBitmap | null>(() => {
46
71
  if (!scene || !bitmapSize) return null;
47
- return renderNativeBarChart(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
48
- }, [bitmapSize, scene]);
72
+ return renderNativeBarChart(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight, hover);
73
+ }, [bitmapSize, hover, scene]);
49
74
 
50
- const textLines = useMemo(() => scene ? renderBarChart(scene) : [], [scene]);
51
- const axis = useMemo(() => scene ? renderBarChartAxis(scene) : "", [scene]);
75
+ const textLines = useMemo(() => scene ? renderBarChart(scene, hover) : [], [hover, scene]);
76
+ const axis = useMemo(() => scene ? renderBarChartAxis(scene, axisRows) : [], [axisRows, scene]);
77
+ const yAxis = useMemo(() => scene ? renderBarChartYAxis(scene, yAxisWidth, formatValue) : [], [formatValue, scene, yAxisWidth]);
78
+ const hoverLabel = useMemo(() => {
79
+ if (!hover) return null;
80
+ const value = formatValue ? formatValue(hover.value) : String(hover.value);
81
+ return series.length > 1 ? `${hover.category} ${hover.seriesLabel}: ${value}` : `${hover.category}: ${value}`;
82
+ }, [formatValue, hover, series.length]);
83
+ const readout = useMemo(() => {
84
+ if (!hoverLabel) return title ?? "";
85
+ return title ? `${title} ${hoverLabel}` : hoverLabel;
86
+ }, [hoverLabel, title]);
87
+ const hoverLabelLayout = useMemo(() => {
88
+ if (!hover || !hoverLabel) return null;
89
+ const text = truncateWithEllipsis(hoverLabel, plotWidth);
90
+ const labelWidth = Math.min(plotWidth, Math.max(1, text.length));
91
+ const labelLeft = Math.max(0, Math.min(
92
+ plotWidth - labelWidth,
93
+ hover.x + Math.floor(hover.width / 2) - Math.floor(labelWidth / 2),
94
+ ));
95
+ const labelTop = hover.value >= 0
96
+ ? Math.max(0, hover.row - 1)
97
+ : Math.min(plotHeight - 1, hover.row + 1);
98
+ return { labelLeft, labelTop, labelWidth, text };
99
+ }, [hover, hoverLabel, plotHeight, plotWidth]);
100
+
101
+ const handleMouseMove = useCallback((event: ChartMouseEvent) => {
102
+ if (!scene) return;
103
+ const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
104
+ if (!pointer) {
105
+ setHover(null);
106
+ onHoverChange?.(null);
107
+ return;
108
+ }
109
+ const nextHover = resolveBarChartHover(scene, pointer.cellX);
110
+ setHover(nextHover);
111
+ onHoverChange?.(nextHover);
112
+ consumeChartMouseEvent(event);
113
+ }, [onHoverChange, renderer, scene]);
114
+
115
+ const handleMouseOut = useCallback(() => {
116
+ setHover(null);
117
+ onHoverChange?.(null);
118
+ }, [onHoverChange]);
119
+
120
+ const handleMouseDown = useCallback((event: ChartMouseEvent) => {
121
+ onMouseDown?.(event);
122
+ if (!scene) return;
123
+ const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
124
+ if (!pointer) return;
125
+ const nextHover = resolveBarChartHover(scene, pointer.cellX);
126
+ setHover(nextHover);
127
+ onHoverChange?.(nextHover);
128
+ consumeChartMouseEvent(event);
129
+ }, [onHoverChange, onMouseDown, renderer, scene]);
52
130
 
53
131
  if (!scene) {
54
132
  return (
55
- <Box width={totalWidth} height={totalHeight}>
133
+ <Box flexDirection="column" width={totalWidth} height={totalHeight} onMouseDown={onMouseDown}>
134
+ {header ?? (title ? <Text fg={colors.textBright} bold>{truncateWithEllipsis(title, totalWidth)}</Text> : null)}
56
135
  <Text fg={colors.textDim}>No chart data</Text>
57
136
  </Box>
58
137
  );
@@ -60,6 +139,13 @@ export function StaticBarChartSurface({
60
139
 
61
140
  return (
62
141
  <Box flexDirection="column" width={totalWidth} height={totalHeight}>
142
+ {headerRows ? (
143
+ header ?? (
144
+ <Text fg={hover ? hover.value < 0 ? colors.negative : colors.textBright : colors.textDim} bold={!!title}>
145
+ {truncateWithEllipsis(readout, totalWidth)}
146
+ </Text>
147
+ )
148
+ ) : null}
63
149
  {legendRows ? (
64
150
  <Text>
65
151
  {series.map((item, index) => (
@@ -69,17 +155,54 @@ export function StaticBarChartSurface({
69
155
  ))}
70
156
  </Text>
71
157
  ) : null}
72
- <ChartSurface
73
- width={totalWidth}
74
- height={plotHeight}
75
- flexDirection="column"
76
- bitmaps={bitmap ? [bitmap] : null}
77
- >
78
- {textLines.map((line, index) => (
79
- <Text key={index} fg={colors.text}>{line}</Text>
80
- ))}
81
- </ChartSurface>
82
- <Text fg={colors.textDim}>{axis}</Text>
158
+ <Box flexDirection="row" width={totalWidth} height={plotHeight}>
159
+ {yAxisWidth ? (
160
+ <Box flexDirection="column" width={yAxisWidth} height={plotHeight}>
161
+ {yAxis.map((line, index) => (
162
+ <Text key={index} fg={colors.textDim}>{line}</Text>
163
+ ))}
164
+ </Box>
165
+ ) : null}
166
+ <Box position="relative" width={plotWidth} height={plotHeight}>
167
+ <ChartSurface
168
+ ref={plotRef}
169
+ width={plotWidth}
170
+ height={plotHeight}
171
+ flexDirection="column"
172
+ bitmaps={bitmap ? [bitmap] : null}
173
+ onMouseMove={handleMouseMove}
174
+ onMouseDown={handleMouseDown}
175
+ onMouseOut={handleMouseOut}
176
+ cursor="pointer"
177
+ data-gloom-interactive="true"
178
+ data-gloom-role="bar-chart-surface"
179
+ >
180
+ {textLines.map((line, index) => (
181
+ <Text key={index} fg={colors.text}>{line}</Text>
182
+ ))}
183
+ </ChartSurface>
184
+ {hoverLabelLayout ? (
185
+ <Box
186
+ position="absolute"
187
+ left={hoverLabelLayout.labelLeft}
188
+ top={hoverLabelLayout.labelTop}
189
+ width={hoverLabelLayout.labelWidth}
190
+ height={1}
191
+ zIndex={1}
192
+ backgroundColor={colors.panel}
193
+ >
194
+ <Text fg={hover && hover.value < 0 ? colors.negative : colors.textBright} bold>
195
+ {hoverLabelLayout.text}
196
+ </Text>
197
+ </Box>
198
+ ) : null}
199
+ </Box>
200
+ </Box>
201
+ {axis.map((line, index) => (
202
+ <Text key={index} fg={colors.textDim}>
203
+ {yAxisWidth ? `${" ".repeat(yAxisWidth)}${line}` : line}
204
+ </Text>
205
+ ))}
83
206
  </Box>
84
207
  );
85
208
  }