gloomberb 0.7.2 → 0.7.3

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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -1,30 +1,70 @@
1
1
  <div align="center">
2
2
 
3
- <img src="src/assets/gloomberb-logo.png" alt="Gloomberb logo" width="96" />
3
+ <img src="https://gloomberb.com/gloomberb-logo-grayscale.svg" alt="Gloomberb logo" width="76" />
4
4
 
5
5
  # Gloomberb
6
6
 
7
- **Modern extensible financial terminal.**
7
+ **Open-source finance terminal. Fast, keyboard-driven and extensible.**
8
8
 
9
- > The Bloomberb Terminal for the rest of us.
9
+ Available as a desktop app or TUI.
10
10
 
11
- <img src="https://gloomberb.com/landing-terminal.png" alt="Gloomberb terminal screenshot" width="720" />
11
+ <a href="https://gloomberb.com/download/desktop"><strong>Download desktop for Mac</strong></a>
12
+
13
+ <br />
14
+ <br />
15
+
16
+ <img src="https://gloomberb.com/landing-terminal.png" alt="Gloomberb terminal showing portfolio, watchlists, market data, and chart panels." width="720" />
12
17
 
13
18
  </div>
14
19
 
15
- ## ✨ Features
20
+ ## Functions
21
+
22
+ Open the command bar with `Ctrl+P` or `` ` ``, then type a shortcut or command name.
23
+
24
+ | Shortcut | Function |
25
+ |----------|----------|
26
+ | `DES <ticker>` | Security details for a ticker |
27
+ | `QQ <tickers>` | Ticker quote monitor |
28
+ | `PM <query>` | Polymarket and Kalshi prediction data |
29
+ | `TOP` | Ranked market stories |
30
+ | `MOST` | Top gainers, losers, most active, and trending tickers |
31
+ | `WEI` | Global equity indices |
32
+ | `ECON` | Economic events and releases |
33
+ | `CMP <tickers>` | Ticker charts |
34
+ | `CORR <tickers>` | Ticker return correlations |
35
+ | `ANR <ticker>` | Analyst targets and ratings |
36
+ | `SEC <ticker>` | SEC filings and company disclosures |
37
+ | `TWIT <query>` | Ticker-related market posts |
38
+ | `OMON <ticker>` | Options monitor |
39
+ | `PORT` | Portfolio risk and sector exposure |
40
+ | `BI` / `SP` | S&P 500 sector performance |
41
+ | `FXC` | Major FX cross rates |
42
+ | `FNG` | Fear and greed market gauge |
43
+ | `ALRT` | Price alerts |
44
+ | `CHAT` | Gloomberb Cloud chat |
45
+ | `PF` | Portfolio and watchlist workspace |
46
+ | `N` | News feed |
47
+ | `CN <ticker>` | Ticker news |
48
+ | `NI` | Sector news |
49
+ | `FIRST` | Breaking news |
50
+ | `NOTE` | Notes |
51
+ | `AI <prompt>` | AI screener |
52
+ | `GC` | Yield curve |
53
+ | `ERN` | Earnings calendar |
54
+ | `HDS <ticker>` | Institutional holders |
55
+ | `INS <ticker>` | Insider activity |
56
+ | `EVT <ticker>` | Corporate actions |
57
+ | `RV <tickers>` | Relative valuation |
58
+ | `IBKR` | IBKR trading pane |
59
+ | `BR` | Broker connections |
60
+
61
+ ## Install
16
62
 
17
- - **Track portfolios & trade**
18
- - **Real-time quotes & fundamentals**
19
- - **Beautiful high-rez charts**
20
- - **Fast & keyboard-driven**
21
- - **Powerful layouts**
22
- - **Extensible**
23
- - **100% local**
63
+ Desktop:
24
64
 
25
- For the best experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminal such as Ghostty, Kitty or WezTerm.
65
+ - [Download Gloomberb for Mac](https://gloomberb.com/download/desktop)
26
66
 
27
- ## 🚀 Install
67
+ Terminal UI:
28
68
 
29
69
  ```bash
30
70
  curl -fsSL gloomberb.com/install | bash
@@ -32,139 +72,46 @@ curl -fsSL gloomberb.com/install | bash
32
72
  bun install -g gloomberb
33
73
  ```
34
74
 
35
- Then run `gloomberb`.
36
-
37
- ## 🧩 Plugins
38
-
39
- Everything, from the portfolio list to broker integrations, is a plugin.
40
-
41
- Plugins can add tabs, columns, command bar commands, CLI commands, status bar widgets, and more.
42
-
43
- See **[PLUGINS.md](PLUGINS.md)** for the plugin API and the shared UI surface available through `gloomberb/components`.
44
-
45
- ### Core plugins
46
-
47
- | Plugin | Description |
48
- |--------|-------------|
49
- | **Portfolio List** | Main ticker list with portfolios & watchlists |
50
- | **Ticker Detail** | Overview, financials, and chart tabs |
51
- | **Gloomberb Cloud** | Near-real-time market data, macro calendar, yield curve, and chat |
52
- | **Manual Entry** | Manually add positions, saved locally |
53
- | **IBKR** | Import positions from Flex Query or trade with Gateway API |
54
- | **News** | Ticker and market-wide news from Cloud, Yahoo fallback, and RSS feeds |
55
- | **SEC** | View recent SEC filings for supported US equities |
56
- | **Options** | View US equity options chains |
57
- | **Notes** | Write and save markdown notes, stored locally |
58
- | **AI** | Keep an Ask AI detail tab for tickers and create prompt-driven AI screener panes |
59
- | **Alerts** | Create price trigger alerts with desktop notifications |
60
- | **Compare Charts** | Compare multiple ticker charts overlaid on one shared chart |
61
- | **Prediction Markets** | Browse Polymarket and Kalshi markets |
62
- | **Correlation** | Compare ticker return correlations |
63
- | **Portfolio Analytics** | Sharpe ratio, beta, and sector allocation |
64
- | **Insider** | View recent insider transactions |
65
- | **World Equity Indices** | Global equity index monitor grouped by region |
66
- | **Market Movers** | Gainers, losers, most active, and trending tickers |
67
- | **FX Cross Rates** | Currency cross-rate matrix for major FX pairs |
68
- | **Sector Performance** | S&P 500 sector performance via ETF proxies |
69
- | **Earnings Calendar** | Upcoming earnings dates and estimates |
70
- | **Research** | Analyst ratings, corporate actions, and relative valuation |
75
+ Then run:
71
76
 
72
- ### Data providers
73
-
74
- | Provider | Description |
75
- |----------|-------------|
76
- | **Gloomberb Cloud** | Real-time data (recommended, free) |
77
- | **Yahoo Finance** | Delayed data, rate-limiting |
78
-
79
- ### Broker connectors
80
-
81
- | Plugin | Description |
82
- |--------|-------------|
83
- | **IBKR** | Import positions from Flex Query or trade with Gateway API |
84
- | **Manual Entry** | Manually add positions, saved locally |
77
+ ```bash
78
+ gloomberb
79
+ ```
85
80
 
86
- Toggleable plugins can be enabled/disabled from the command bar screen (`Ctrl+p`).
81
+ For the best terminal experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminal such as Ghostty, Kitty, or WezTerm.
87
82
 
88
- ## 💻 CLI
83
+ ## CLI
89
84
 
90
85
  Running `gloomberb` with no arguments launches the terminal UI. Use `gloomberb help` to see the full command list.
91
- Feature-owned root commands such as `portfolio`, `watchlist`, and `predictions` are registered by their plugins rather than hardcoded in the main CLI switch.
92
86
 
93
87
  | Command | Use |
94
88
  |---------|-----|
95
89
  | `gloomberb` | Launch the terminal UI |
96
- | `gloomberb help` | Show all CLI commands and plugin-owned help |
90
+ | `gloomberb help` | Show all CLI commands |
97
91
  | `gloomberb search <query>` | Search tickers and company names |
98
92
  | `gloomberb ticker <symbol>` | Show quote, ownership, and financials |
99
- | `gloomberb portfolio [action]` | List, inspect, create, delete, and manage manual portfolios |
100
- | `gloomberb watchlist [action]` | List, inspect, create, delete, and manage watchlists |
101
- | `gloomberb predictions [...]` | Launch the UI with Prediction Markets focused |
93
+ | `gloomberb portfolio [action]` | Manage manual portfolios |
94
+ | `gloomberb watchlist [action]` | Manage watchlists |
95
+ | `gloomberb predictions [...]` | Launch Prediction Markets |
102
96
  | `gloomberb plugins` | List installed plugins |
103
97
  | `gloomberb install <user/repo>` | Install a plugin from GitHub |
104
98
  | `gloomberb remove <name>` | Remove an installed plugin |
105
99
  | `gloomberb update [name]` | Update plugins |
106
100
 
107
- ```bash
108
- gloomberb
109
- gloomberb help
110
- gloomberb search NVDA
111
- gloomberb ticker AAPL
112
- gloomberb portfolio list
113
- gloomberb portfolio show Research
114
- gloomberb portfolio create Research
115
- gloomberb portfolio add Research ASML
116
- gloomberb portfolio position set Research NVDA 10 400
117
- gloomberb watchlist list
118
- gloomberb watchlist add Growth NVDA
119
- gloomberb predictions kalshi macro top fed
120
- gloomberb plugins
121
- gloomberb update
122
- ```
101
+ ## Plugins
123
102
 
124
- ## Command Bar Shortcuts
103
+ Everything from the portfolio list to broker integrations is a plugin. Plugins can add panes, tabs, columns, command bar commands, CLI commands, status bar widgets, and data providers.
125
104
 
126
- Open the command bar with `Ctrl+P` or `` ` ``, then type a shortcut or command name.
105
+ Core plugin areas include:
127
106
 
128
- | Shortcut | Opens |
129
- |----------|-------|
130
- | `PF` | Collection Pane |
131
- | `DES <ticker>` | Ticker Detail |
132
- | `QQ <ticker>` | Quote Monitor |
133
- | `CHAT` | New Chat Pane |
134
- | `IBKR` | New IBKR Trading Pane |
135
- | `NOTE` | Notes |
136
- | `AI <prompt>` | AI Screener |
137
- | `CMP <tickers>` | Comparison Chart |
138
- | `PM <query>` | Prediction Markets |
139
- | `CORR <tickers>` | Correlation Matrix |
140
- | `PORT` | Portfolio Analytics |
141
- | `ECON` | Economic Calendar |
142
- | `WEI` | World Equity Indices |
143
- | `MOST` | Market Movers |
144
- | `TOP` | Top News |
145
- | `N` | News Feed |
146
- | `CN <ticker>` | Ticker News |
147
- | `NI` | Industry News |
148
- | `FIRST` | Breaking News |
149
- | `ALRT` | Alerts |
150
- | `FXC` | FX Cross Rates |
151
- | `GC` | Yield Curve |
152
- | `BI` | Sector Performance |
153
- | `ERN` | Earnings Calendar |
154
- | `OMON <ticker>` | Options |
155
- | `HDS <ticker>` | Holders |
156
- | `ANR <ticker>` | Analyst Research |
157
- | `EVT <ticker>` | Corporate Actions |
158
- | `SEC <ticker>` | SEC Filings |
159
- | `INS <ticker>` | Insider Activity |
160
- | `RV <tickers>` | Relative Valuation |
161
-
162
- | Shortcut | Command |
163
- |----------|---------|
164
- | `GL` | Gridlock all windows |
165
- | `SA <symbol condition price>` | Add Alert, e.g. `SA AMD above 200` |
166
-
167
- ## ⌨️ Keyboard Shortcuts
107
+ - Portfolios, watchlists, manual entry, and broker connections
108
+ - Ticker details, quotes, charts, options, filings, holders, insiders, and research
109
+ - News, market movers, global indices, sectors, FX, earnings, macro data, and yield curves
110
+ - Prediction markets, alerts, notes, chat, AI screeners, and external plugins
111
+
112
+ See [PLUGINS.md](PLUGINS.md) for the plugin API and the shared UI surface available through `gloomberb/components`.
113
+
114
+ ## Keyboard
168
115
 
169
116
  | Key | Action |
170
117
  |-----|--------|
@@ -174,10 +121,10 @@ Open the command bar with `Ctrl+P` or `` ` ``, then type a shortcut or command n
174
121
  | `Ctrl+Shift+D` | Dock or float focused pane |
175
122
  | `Ctrl+Shift+L` | Layout actions |
176
123
  | `Ctrl+Shift+G` | Gridlock all windows |
177
- | `Tab` | Switch between panels |
178
- | `j` / `k` | Navigate ticker list |
124
+ | `Tab` | Switch panes |
125
+ | `j` / `k` | Navigate lists |
179
126
  | `h` / `l` | Switch tabs |
180
- | `m` | Cycle chart mode in the chart tab |
127
+ | `m` | Cycle chart mode |
181
128
  | `q` | Quit |
182
129
 
183
130
  Desktop builds also accept the matching `Cmd` shortcuts on macOS, plus `Cmd/Ctrl+Shift+O` to pop out a pane.
@@ -188,4 +135,4 @@ MIT
188
135
 
189
136
  ## Credits
190
137
 
191
- - [OpenTUI](https://opentui.com/) for the layout
138
+ - [OpenTUI](https://opentui.com/) for the layout engine
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  AppProvider,
7
7
  getFocusedCollectionId,
8
8
  getFocusedTickerSymbol,
9
+ syncConfigActiveLayoutState,
9
10
  useAppDispatch,
10
11
  useAppSelector,
11
12
  useAppStateRef,
@@ -96,7 +97,8 @@ import {
96
97
  } from "./components/command-bar/workflow-ops";
97
98
  import { getPaneTemplateDisplayLabel } from "./components/command-bar/pane-template-display";
98
99
  import {
99
- resolveTickerNavigationDetailPane,
100
+ findFixedTickerPaneForSymbol,
101
+ resolveTickerNavigationReplacementPane,
100
102
  shouldFocusTickerNavigationTarget,
101
103
  } from "./plugins/ticker-navigation";
102
104
  import { debugLog } from "./utils/debug-log";
@@ -348,11 +350,13 @@ function AppInner({
348
350
  if (!ensured) return null;
349
351
  if (ensured.layout !== state.config.layout) {
350
352
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
351
- const layouts = state.config.layouts.map((savedLayout, index) => (
352
- index === state.config.activeLayoutIndex ? { ...savedLayout, layout: ensured.layout } : savedLayout
353
- ));
354
353
  dispatch({ type: "UPDATE_LAYOUT", layout: ensured.layout });
355
- scheduleConfigSave({ ...state.config, layout: ensured.layout, layouts });
354
+ scheduleConfigSave(syncConfigActiveLayoutState(
355
+ { ...state.config, layout: ensured.layout },
356
+ state.paneState,
357
+ state.focusedPaneId,
358
+ state.activePanel,
359
+ ));
356
360
  }
357
361
  return ensured.instance.instanceId;
358
362
  })();
@@ -1078,14 +1082,16 @@ function AppInner({
1078
1082
  const persistLayout = (layout: LayoutConfig, options?: { pushHistory?: boolean }) => {
1079
1083
  const currentState = stateRef.current;
1080
1084
  const normalizedLayout = normalizePaneLayout(layout);
1081
- const layouts = currentState.config.layouts.map((savedLayout, index) => (
1082
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout: normalizedLayout } : savedLayout
1083
- ));
1084
1085
  if (options?.pushHistory !== false) {
1085
1086
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
1086
1087
  }
1087
1088
  dispatch({ type: "UPDATE_LAYOUT", layout: normalizedLayout });
1088
- scheduleConfigSave({ ...currentState.config, layout: normalizedLayout, layouts });
1089
+ scheduleConfigSave(syncConfigActiveLayoutState(
1090
+ { ...currentState.config, layout: normalizedLayout },
1091
+ currentState.paneState,
1092
+ currentState.focusedPaneId,
1093
+ currentState.activePanel,
1094
+ ));
1089
1095
  };
1090
1096
  const resolvePanelForPane = useCallback((paneId: string, layout: LayoutConfig = state.config.layout): "left" | "right" => {
1091
1097
  const instanceId = resolvePaneTarget(paneId, layout);
@@ -1109,6 +1115,16 @@ function AppInner({
1109
1115
  dispatch({ type: "SET_ACTIVE_PANEL", panel: resolvePanelForPane(paneId, layout) });
1110
1116
  dispatch({ type: "FOCUS_PANE", paneId });
1111
1117
  }, [dispatch, resolvePanelForPane, state.config.layout]);
1118
+ const focusVisiblePane = (paneId: string, layout: LayoutConfig = state.config.layout) => {
1119
+ const nextLayout = layout.floating.some((entry) => entry.instanceId === paneId)
1120
+ ? bringToFront(layout, paneId)
1121
+ : layout;
1122
+
1123
+ if (nextLayout !== state.config.layout) {
1124
+ persistLayout(nextLayout, { pushHistory: false });
1125
+ }
1126
+ activatePane(paneId, nextLayout);
1127
+ };
1112
1128
  const placePaneInstance = useCallback((
1113
1129
  instance: PaneInstanceConfig,
1114
1130
  paneDef: NonNullable<ReturnType<typeof pluginRegistry.panes.get>>,
@@ -1341,20 +1357,20 @@ function AppInner({
1341
1357
  return;
1342
1358
  }
1343
1359
 
1344
- const layout = state.config.layout.floating.some((entry) => entry.instanceId === instanceId)
1345
- ? bringToFront(state.config.layout, instanceId)
1346
- : state.config.layout;
1347
-
1348
- if (layout !== state.config.layout) {
1349
- persistLayout(layout, { pushHistory: false });
1350
- }
1351
- activatePane(instanceId, layout);
1360
+ focusVisiblePane(instanceId);
1352
1361
  };
1353
1362
  pluginRegistry.pinTickerFn = (symbol, options) => {
1354
1363
  if (isDetachedWindow) return;
1355
1364
  const paneType = options?.paneType ?? "ticker-detail";
1356
1365
  const paneDef = pluginRegistry.panes.get(paneType);
1357
1366
  if (!paneDef) return;
1367
+ const existing = options?.forceNewPane
1368
+ ? null
1369
+ : findFixedTickerPaneForSymbol(state.config.layout, paneType, symbol);
1370
+ if (existing) {
1371
+ focusVisiblePane(existing.instanceId);
1372
+ return;
1373
+ }
1358
1374
  const instance = buildPaneInstance(paneType, {
1359
1375
  title: symbol,
1360
1376
  binding: { kind: "fixed", symbol },
@@ -1383,52 +1399,43 @@ function AppInner({
1383
1399
  const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
1384
1400
  (async () => {
1385
1401
  try {
1386
- // Resolve or create the ticker in the local database
1387
- const resolved = await resolveTickerSearch({
1388
- query: rawSymbol,
1389
- activeTicker: null,
1390
- tickers: stateRef.current.tickers,
1391
- dataProvider,
1392
- });
1402
+ // Resolve or create the ticker in the local database
1403
+ const resolved = await resolveTickerSearch({
1404
+ query: rawSymbol,
1405
+ activeTicker: null,
1406
+ tickers: stateRef.current.tickers,
1407
+ dataProvider,
1408
+ });
1393
1409
 
1394
- let symbol = rawSymbol;
1395
- if (resolved?.kind === "local") {
1396
- symbol = resolved.symbol;
1397
- } else if (resolved?.kind === "provider" && resolved.result) {
1398
- const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1399
- symbol = ticker.metadata.ticker;
1400
- dispatch({ type: "UPDATE_TICKER", ticker });
1401
- if (created) {
1402
- pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1410
+ let symbol = rawSymbol;
1411
+ if (resolved?.kind === "local") {
1412
+ symbol = resolved.symbol;
1413
+ } else if (resolved?.kind === "provider" && resolved.result) {
1414
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1415
+ symbol = ticker.metadata.ticker;
1416
+ dispatch({ type: "UPDATE_TICKER", ticker });
1417
+ if (created) {
1418
+ pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1419
+ }
1403
1420
  }
1404
- }
1405
1421
 
1406
- // Active panel resolution — navigate the initiating or linked detail pane:
1407
- // 1. If the source pane IS a ticker-detail, retarget it directly
1408
- // 2. If a ticker-detail follows the source pane, retarget that
1409
- // 3. Any follow-mode ticker-detail in the layout
1410
- // 4. Any ticker-detail in the layout
1411
- // 5. Fall back to pinning a new pane
1412
- const currentState = stateRef.current;
1413
- const currentLayout = currentState.config.layout;
1414
- const detailPane = resolveTickerNavigationDetailPane(currentLayout, sourcePaneId);
1415
- const focusIfStillOwned = (paneId: string, layout: LayoutConfig) => {
1416
- if (!shouldFocusTickerNavigationTarget({
1417
- sourcePaneId,
1418
- currentFocusedPaneId: stateRef.current.focusedPaneId,
1419
- targetPaneId: paneId,
1420
- })) {
1421
- return;
1422
- }
1423
- activatePane(paneId, layout);
1424
- };
1422
+ // Only actions that originate from a ticker detail replace that exact pane.
1423
+ // Everything else opens or focuses a fixed ticker detail for the symbol.
1424
+ const currentState = stateRef.current;
1425
+ const currentLayout = currentState.config.layout;
1426
+ const detailPane = resolveTickerNavigationReplacementPane(currentLayout, sourcePaneId);
1427
+ const focusIfStillOwned = (paneId: string, layout: LayoutConfig) => {
1428
+ if (!shouldFocusTickerNavigationTarget({
1429
+ sourcePaneId,
1430
+ currentFocusedPaneId: stateRef.current.focusedPaneId,
1431
+ targetPaneId: paneId,
1432
+ })) {
1433
+ return;
1434
+ }
1435
+ activatePane(paneId, layout);
1436
+ };
1425
1437
 
1426
- if (detailPane) {
1427
- if (detailPane.binding?.kind === "follow") {
1428
- const sourceId = detailPane.binding.sourceInstanceId;
1429
- dispatch({ type: "UPDATE_PANE_STATE", paneId: sourceId, patch: { cursorSymbol: symbol } });
1430
- focusIfStillOwned(detailPane.instanceId, currentLayout);
1431
- } else {
1438
+ if (detailPane) {
1432
1439
  const nextLayout = {
1433
1440
  ...currentLayout,
1434
1441
  instances: currentLayout.instances.map((instance) => (
@@ -1439,14 +1446,13 @@ function AppInner({
1439
1446
  };
1440
1447
  persistLayout(nextLayout);
1441
1448
  focusIfStillOwned(detailPane.instanceId, nextLayout);
1449
+ } else if (shouldFocusTickerNavigationTarget({
1450
+ sourcePaneId,
1451
+ currentFocusedPaneId: stateRef.current.focusedPaneId,
1452
+ targetPaneId: null,
1453
+ })) {
1454
+ pluginRegistry.pinTicker(symbol, { floating: false });
1442
1455
  }
1443
- } else if (shouldFocusTickerNavigationTarget({
1444
- sourcePaneId,
1445
- currentFocusedPaneId: stateRef.current.focusedPaneId,
1446
- targetPaneId: null,
1447
- })) {
1448
- pluginRegistry.pinTicker(symbol, { floating: false });
1449
- }
1450
1456
  } catch (err) {
1451
1457
  const message = err instanceof Error ? err.message : String(err);
1452
1458
  pluginRegistry.notify({ body: `Failed to navigate to ${rawSymbol}: ${message}`, type: "error" });
@@ -1588,7 +1594,7 @@ function AppInner({
1588
1594
  }
1589
1595
  }
1590
1596
  }
1591
- });
1597
+ }, { phase: "before" });
1592
1598
 
1593
1599
  if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
1594
1600
  return (
@@ -43,6 +43,10 @@ function getRequestedRenderMode(mode?: ChartRenderMode, compact = false): ChartR
43
43
  return mode ?? "area";
44
44
  }
45
45
 
46
+ function isOhlcProjectionMode(mode: ChartRenderMode): boolean {
47
+ return mode === "candles" || mode === "ohlc" || mode === "hlc";
48
+ }
49
+
46
50
  export function resolveRenderMode(
47
51
  mode: ChartRenderMode | undefined,
48
52
  chartWidth: number,
@@ -64,6 +68,7 @@ export function resolveRenderMode(
64
68
  else if (chartWidth < 40) effectiveMode = "ohlc";
65
69
  break;
66
70
  case "ohlc":
71
+ case "hlc":
67
72
  if (chartWidth < 28) effectiveMode = "line";
68
73
  break;
69
74
  }
@@ -328,11 +333,11 @@ export function projectChartData(
328
333
  ): ChartProjection {
329
334
  const { requestedMode, effectiveMode, fallbackMode } = resolveRenderMode(mode, targetWidth, compact);
330
335
  const ohlcBucketWidth = options.ohlcBucketWidth ?? targetWidth;
331
- const projectionWidth = effectiveMode === "candles" || effectiveMode === "ohlc"
336
+ const projectionWidth = isOhlcProjectionMode(effectiveMode)
332
337
  ? Math.max(Math.floor(ohlcBucketWidth / 2), 1)
333
338
  : targetWidth;
334
339
 
335
- const projectedPoints = effectiveMode === "candles" || effectiveMode === "ohlc"
340
+ const projectedPoints = isOhlcProjectionMode(effectiveMode)
336
341
  ? bucketOhlcSeries(points, projectionWidth, options)
337
342
  : projectCloseSeries(points, projectionWidth);
338
343
 
@@ -4,6 +4,7 @@ import {
4
4
  useAppStateRef,
5
5
  usePaneInstance,
6
6
  usePaneInstanceId,
7
+ syncConfigActiveLayoutState,
7
8
  } from "../../state/app-context";
8
9
  import { scheduleConfigSave } from "../../state/config-save-scheduler";
9
10
  import type { ChartResolution, TimeRange } from "./chart-types";
@@ -24,10 +25,12 @@ export function usePersistChartControlSelection(rangePresetKey: string): (
24
25
  [rangePresetKey]: range,
25
26
  chartResolution: resolution,
26
27
  });
27
- const layouts = currentState.config.layouts.map((savedLayout, index) => (
28
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
29
- ));
30
- const nextConfig = { ...currentState.config, layout, layouts };
28
+ const nextConfig = syncConfigActiveLayoutState(
29
+ { ...currentState.config, layout },
30
+ currentState.paneState,
31
+ currentState.focusedPaneId,
32
+ currentState.activePanel,
33
+ );
31
34
  dispatch({ type: "SET_CONFIG", config: nextConfig });
32
35
  scheduleConfigSave(nextConfig);
33
36
  };
@@ -278,15 +278,41 @@ function getBarHalfWidth(pointCount: number, bufWidth: number): number {
278
278
  return Math.floor(barW / 2);
279
279
  }
280
280
 
281
+ function isOhlcLikeMode(mode: ChartRenderMode): boolean {
282
+ return mode === "ohlc" || mode === "hlc";
283
+ }
284
+
285
+ function isHighLowMode(mode: ChartRenderMode): boolean {
286
+ return mode === "candles" || isOhlcLikeMode(mode);
287
+ }
288
+
289
+ function getOhlcTickLength(pointCount: number, bufWidth: number): number {
290
+ if (pointCount <= 1) return 4;
291
+ const spacing = bufWidth / pointCount;
292
+ return Math.min(Math.max(Math.round(spacing * 0.45), 3), 5);
293
+ }
294
+
295
+ function getOhlcStemWidth(pointCount: number, bufWidth: number): number {
296
+ if (pointCount <= 1) return 3;
297
+ const spacing = bufWidth / pointCount;
298
+ return Math.min(Math.max(Math.round(spacing * 0.2), 2), 3);
299
+ }
300
+
301
+ function getOhlcHorizontalPad(pointCount: number, bufWidth: number): number {
302
+ const tickLen = getOhlcTickLength(pointCount, bufWidth);
303
+ const stemWidth = getOhlcStemWidth(pointCount, bufWidth);
304
+ return Math.max(tickLen - 1, Math.ceil((stemWidth - 1) / 2), 0);
305
+ }
306
+
281
307
  function getDotX(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
282
308
  switch (mode) {
283
309
  case "candles": {
284
310
  const pad = getCandleHalfWidth(pointCount, width);
285
311
  return getSeriesPosition(index, pointCount, width, pad, pad);
286
312
  }
287
- case "ohlc": {
288
- const tickLen = Math.max(getCandleHalfWidth(pointCount, width), 2);
289
- const pad = Math.max(tickLen - 1, 0);
313
+ case "ohlc":
314
+ case "hlc": {
315
+ const pad = getOhlcHorizontalPad(pointCount, width);
290
316
  return getSeriesPosition(index, pointCount, width, pad, pad);
291
317
  }
292
318
  default:
@@ -415,12 +441,16 @@ function drawOhlcChart(
415
441
  palette: ResolvedChartPalette,
416
442
  min: number,
417
443
  max: number,
444
+ mode: Extract<ChartRenderMode, "ohlc" | "hlc">,
418
445
  ) {
419
- const tickLen = Math.max(getCandleHalfWidth(points.length, buf.width), 2);
446
+ const tickLen = getOhlcTickLength(points.length, buf.width);
447
+ const stemWidth = getOhlcStemWidth(points.length, buf.width);
448
+ const stemLeft = Math.floor((stemWidth - 1) / 2);
449
+ const stemRight = Math.ceil((stemWidth - 1) / 2);
420
450
 
421
451
  for (let i = 0; i < points.length; i++) {
422
452
  const point = points[i]!;
423
- const x = getDotX(i, points.length, buf.width, "ohlc");
453
+ const x = getDotX(i, points.length, buf.width, mode);
424
454
  const highY = getScaledY(point.high, min, max, chartTop, chartBottom);
425
455
  const lowY = getScaledY(point.low, min, max, chartTop, chartBottom);
426
456
  const openY = getScaledY(point.open, min, max, chartTop, chartBottom);
@@ -428,14 +458,15 @@ function drawOhlcChart(
428
458
  const isUp = point.close >= point.open;
429
459
  const color = isUp ? palette.candleUp : palette.candleDown;
430
460
 
431
- // Vertical line
432
- drawLine(buf, x, highY, x, lowY, color, LAYER_DATA);
461
+ for (let dx = -stemLeft; dx <= stemRight; dx++) {
462
+ drawLine(buf, x + dx, highY, x + dx, lowY, color, LAYER_DATA);
463
+ }
433
464
 
434
- // Open tick extends left
435
- for (let dx = 0; dx < tickLen; dx++) {
436
- setPixel(buf, x - dx, openY, color, LAYER_DATA);
465
+ if (mode === "ohlc") {
466
+ for (let dx = 0; dx < tickLen; dx++) {
467
+ setPixel(buf, x - dx, openY, color, LAYER_DATA);
468
+ }
437
469
  }
438
- // Close tick extends right
439
470
  for (let dx = 0; dx < tickLen; dx++) {
440
471
  setPixel(buf, x + dx, closeY, color, LAYER_DATA);
441
472
  }
@@ -1222,10 +1253,10 @@ export function buildChartScene(
1222
1253
  if (points.length === 0) return null;
1223
1254
 
1224
1255
  const dimensions = normalizeRenderDimensions(opts);
1225
- const dataMin = opts.mode === "candles" || opts.mode === "ohlc"
1256
+ const dataMin = isHighLowMode(opts.mode)
1226
1257
  ? Math.min(...points.map((point) => point.low))
1227
1258
  : Math.min(...points.map((point) => point.close));
1228
- const dataMax = opts.mode === "candles" || opts.mode === "ohlc"
1259
+ const dataMax = isHighLowMode(opts.mode)
1229
1260
  ? Math.max(...points.map((point) => point.high))
1230
1261
  : Math.max(...points.map((point) => point.close));
1231
1262
  const indicatorValues = getIndicatorOverlayValues(opts.indicators);
@@ -1394,7 +1425,10 @@ export function renderChart(
1394
1425
  drawCandlestickChart(buf, points, 0, chartDotBottom, palette, min, max);
1395
1426
  break;
1396
1427
  case "ohlc":
1397
- drawOhlcChart(buf, points, 0, chartDotBottom, palette, min, max);
1428
+ drawOhlcChart(buf, points, 0, chartDotBottom, palette, min, max, "ohlc");
1429
+ break;
1430
+ case "hlc":
1431
+ drawOhlcChart(buf, points, 0, chartDotBottom, palette, min, max, "hlc");
1398
1432
  break;
1399
1433
  }
1400
1434
 
@@ -1406,7 +1440,7 @@ export function renderChart(
1406
1440
  volDotBottom,
1407
1441
  palette.volumeUp,
1408
1442
  palette.volumeDown,
1409
- mode === "candles" || mode === "ohlc" ? "openClose" : "previousClose",
1443
+ isHighLowMode(mode) ? "openClose" : "previousClose",
1410
1444
  mode,
1411
1445
  );
1412
1446
  }