gloomberb 0.5.1 → 0.6.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 (500) hide show
  1. package/README.md +91 -11
  2. package/package.json +12 -5
  3. package/src/app.tsx +414 -191
  4. package/src/architecture/import-boundaries.test.ts +118 -0
  5. package/src/assets/gloomberb-logo.png +0 -0
  6. package/src/assets/gloomberb-logo.svg +73 -0
  7. package/src/brokers/account-cache.ts +157 -0
  8. package/src/brokers/profile-form.test.ts +73 -0
  9. package/src/brokers/profile-form.ts +109 -0
  10. package/src/brokers/remote-broker-adapter.ts +91 -0
  11. package/src/brokers/sync-broker-instance.test.ts +124 -1
  12. package/src/brokers/sync-broker-instance.ts +113 -17
  13. package/src/capabilities/factories.ts +100 -0
  14. package/src/capabilities/index.ts +4 -0
  15. package/src/capabilities/registry.test.ts +137 -0
  16. package/src/capabilities/registry.ts +131 -0
  17. package/src/capabilities/service-capabilities.ts +27 -0
  18. package/src/capabilities/types.ts +84 -0
  19. package/src/cli/commands/ticker.ts +11 -3
  20. package/src/cli/context.ts +19 -13
  21. package/src/cli/helpers.ts +1 -0
  22. package/src/cli/types.ts +2 -2
  23. package/src/components/chart/chart-controller.ts +4 -24
  24. package/src/components/chart/chart-data.ts +3 -14
  25. package/src/components/chart/chart-indicator-selector.tsx +28 -6
  26. package/src/components/chart/chart-pane-settings.ts +15 -28
  27. package/src/components/chart/chart-renderer.ts +74 -99
  28. package/src/components/chart/chart-resolution.ts +10 -10
  29. package/src/components/chart/chart-scroll.test.ts +6 -5
  30. package/src/components/chart/chart-scroll.ts +2 -2
  31. package/src/components/chart/chart-types.ts +0 -1
  32. package/src/components/chart/chart-viewport.ts +2 -1
  33. package/src/components/chart/chart.test.ts +37 -0
  34. package/src/components/chart/comparison-chart-data.ts +3 -17
  35. package/src/components/chart/comparison-chart-renderer.test.ts +27 -4
  36. package/src/components/chart/comparison-chart-renderer.ts +1 -1
  37. package/src/components/chart/comparison-stock-chart.tsx +304 -108
  38. package/src/components/chart/cursor-motion.ts +3 -2
  39. package/src/components/chart/indicators/bands.test.ts +0 -14
  40. package/src/components/chart/indicators/moving-averages.test.ts +0 -15
  41. package/src/components/chart/indicators/options.ts +3 -3
  42. package/src/components/chart/indicators/oscillators.test.ts +0 -21
  43. package/src/components/chart/indicators/types.ts +1 -2
  44. package/src/components/chart/native/chart-rasterizer.ts +37 -44
  45. package/src/components/chart/native/kitty-adapter.ts +2 -5
  46. package/src/components/chart/native/kitty-manager.ts +1 -1
  47. package/src/components/chart/native/kitty-protocol.ts +0 -1
  48. package/src/components/chart/native/kitty-support.ts +1 -1
  49. package/src/components/chart/native/kitty.test.ts +30 -13
  50. package/src/components/chart/native/renderer-selection.ts +1 -1
  51. package/src/components/chart/native/surface-manager.ts +1 -1
  52. package/src/components/chart/static-chart-surface.test.tsx +51 -0
  53. package/src/components/chart/static-chart-surface.tsx +191 -0
  54. package/src/components/chart/stock-chart-renderer-switch.test.tsx +242 -35
  55. package/src/components/chart/stock-chart.tsx +438 -141
  56. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  57. package/src/components/command-bar/command-bar.test.tsx +269 -20
  58. package/src/components/command-bar/command-bar.tsx +830 -353
  59. package/src/components/command-bar/command-registry.ts +10 -20
  60. package/src/components/command-bar/helpers.test.ts +1 -1
  61. package/src/components/command-bar/helpers.ts +9 -11
  62. package/src/components/command-bar/pane-template-display.ts +4 -8
  63. package/src/components/command-bar/root-shortcuts.ts +12 -11
  64. package/src/components/command-bar/view-model.test.ts +8 -42
  65. package/src/components/command-bar/view-model.ts +8 -36
  66. package/src/components/command-bar/workflow-ops.test.ts +76 -2
  67. package/src/components/command-bar/workflow-ops.ts +40 -9
  68. package/src/components/command-bar/workflow-types.ts +2 -1
  69. package/src/components/data-table-stack-view.test.tsx +206 -0
  70. package/src/components/data-table-stack-view.tsx +152 -0
  71. package/src/components/data-table-view.test.tsx +188 -0
  72. package/src/components/data-table-view.tsx +279 -0
  73. package/src/components/feed-data-table-stack-view.test.tsx +225 -0
  74. package/src/components/feed-data-table-stack-view.tsx +365 -0
  75. package/src/components/index.ts +26 -31
  76. package/src/components/layout/detached-pane-shell.tsx +185 -0
  77. package/src/components/layout/floating-pane.tsx +87 -14
  78. package/src/components/layout/header.tsx +139 -49
  79. package/src/components/layout/pane-content.tsx +40 -0
  80. package/src/components/layout/pane-footer.test.tsx +195 -0
  81. package/src/components/layout/pane-footer.tsx +410 -0
  82. package/src/components/layout/pane-header.test.tsx +39 -0
  83. package/src/components/layout/pane-header.tsx +192 -14
  84. package/src/components/layout/pane-sizing.ts +9 -10
  85. package/src/components/layout/pane-title.ts +35 -0
  86. package/src/components/layout/pane.tsx +52 -11
  87. package/src/components/layout/shell.test.tsx +315 -107
  88. package/src/components/layout/shell.tsx +1010 -368
  89. package/src/components/layout/status-bar.test.tsx +17 -10
  90. package/src/components/layout/status-bar.tsx +367 -100
  91. package/src/components/layout/titlebar-overlay.ts +2 -0
  92. package/src/components/markdown-editor.tsx +29 -14
  93. package/src/components/markdown-text.tsx +25 -24
  94. package/src/components/onboarding/onboarding-wizard.test.tsx +8 -3
  95. package/src/components/onboarding/onboarding-wizard.tsx +347 -473
  96. package/src/components/pane-settings-dialog.tsx +521 -89
  97. package/src/components/pane-template-wizard.test.tsx +1 -1
  98. package/src/components/pane-template-wizard.tsx +38 -37
  99. package/src/components/price-selector-dialog.tsx +22 -21
  100. package/src/components/ticker-badge-list.tsx +53 -0
  101. package/src/components/ticker-badge-text.test.tsx +44 -1
  102. package/src/components/ticker-badge-text.tsx +78 -38
  103. package/src/components/ticker-badge.tsx +28 -7
  104. package/src/components/ticker-list-table-view.tsx +248 -0
  105. package/src/components/ticker-list-table.test.tsx +47 -1
  106. package/src/components/ticker-list-table.tsx +212 -41
  107. package/src/components/toggle-list.tsx +114 -8
  108. package/src/components/ui/button.tsx +24 -16
  109. package/src/components/ui/choice-dialog.tsx +89 -0
  110. package/src/components/ui/data-table.tsx +313 -114
  111. package/src/components/ui/external-link.tsx +52 -13
  112. package/src/components/ui/fields.tsx +44 -30
  113. package/src/components/ui/frame.tsx +24 -57
  114. package/src/components/ui/index.ts +15 -50
  115. package/src/components/ui/list-view.tsx +65 -25
  116. package/src/components/ui/loading.tsx +5 -60
  117. package/src/components/ui/message-composer.test.tsx +81 -0
  118. package/src/components/ui/message-composer.tsx +139 -0
  119. package/src/components/ui/multi-select-dialog.tsx +100 -32
  120. package/src/components/ui/multi-select.ts +112 -0
  121. package/src/components/ui/native-select.tsx +120 -0
  122. package/src/components/ui/page-stack-view.tsx +49 -19
  123. package/src/components/ui/remote-image.tsx +75 -0
  124. package/src/components/ui/row-value-cache.test.ts +17 -0
  125. package/src/components/ui/row-value-cache.ts +41 -0
  126. package/src/components/ui/shortcut-hint.tsx +55 -0
  127. package/src/components/ui/status.tsx +12 -67
  128. package/src/components/ui/table-layout.ts +59 -0
  129. package/src/components/ui/tabs.tsx +249 -27
  130. package/src/components/ui/toggle.tsx +19 -127
  131. package/src/components/ui/ui.test.tsx +266 -53
  132. package/src/components/use-double-click-activation.ts +7 -2
  133. package/src/components/use-external-link-footer.ts +79 -0
  134. package/src/core/app-services.ts +95 -0
  135. package/src/core/state/app-state.ts +886 -0
  136. package/src/{state → core/state}/session-persistence.ts +7 -6
  137. package/src/data/config-layout.ts +225 -0
  138. package/src/data/config-store-node.ts +390 -0
  139. package/src/data/config-store.test.ts +72 -0
  140. package/src/data/config-store.ts +35 -478
  141. package/src/data/sqlite-json.ts +2 -5
  142. package/src/data/sqlite-retry.ts +1 -0
  143. package/src/index.tsx +2 -68
  144. package/src/market-data/coordinator-subscriptions.test.ts +125 -0
  145. package/src/market-data/coordinator.test.ts +73 -4
  146. package/src/market-data/coordinator.ts +190 -24
  147. package/src/market-data/hooks.test.tsx +1 -1
  148. package/src/market-data/hooks.tsx +109 -87
  149. package/src/market-data/query-store.ts +3 -3
  150. package/src/market-data/request-types.test.ts +0 -14
  151. package/src/market-data/result-types.ts +1 -0
  152. package/src/news/aggregator.test.ts +267 -0
  153. package/src/news/aggregator.ts +452 -0
  154. package/src/news/hooks.ts +39 -0
  155. package/src/news/ticker-symbols.ts +21 -0
  156. package/src/news/types.ts +77 -0
  157. package/src/notifications/app-notifier.ts +21 -10
  158. package/src/pane-settings.test.ts +0 -2
  159. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +124 -134
  160. package/src/plugins/builtin/ai/gloomberb-cli.ts +14 -13
  161. package/src/plugins/builtin/ai/providers.ts +1 -9
  162. package/src/plugins/builtin/ai/runner.ts +41 -5
  163. package/src/plugins/builtin/ai/screener-pane.test.tsx +37 -55
  164. package/src/plugins/builtin/ai/screener-pane.tsx +231 -276
  165. package/src/plugins/builtin/ai/settings.ts +0 -1
  166. package/src/plugins/builtin/ai/utils.ts +1 -71
  167. package/src/plugins/builtin/alerts/alert-engine.test.ts +1 -38
  168. package/src/plugins/builtin/alerts/index.test.tsx +61 -82
  169. package/src/plugins/builtin/alerts/index.tsx +263 -155
  170. package/src/plugins/builtin/alerts/types.ts +7 -0
  171. package/src/plugins/builtin/analytics/index.test.tsx +13 -8
  172. package/src/plugins/builtin/analytics/index.tsx +78 -120
  173. package/src/plugins/builtin/analytics/metrics.test.ts +0 -4
  174. package/src/plugins/builtin/ask-ai.test.tsx +55 -54
  175. package/src/plugins/builtin/broker-manager/index.test.tsx +168 -0
  176. package/src/plugins/builtin/broker-manager/index.tsx +759 -0
  177. package/src/plugins/builtin/broker-manager/model.test.ts +107 -0
  178. package/src/plugins/builtin/broker-manager/model.ts +142 -0
  179. package/src/plugins/builtin/chat-controller.test.ts +129 -0
  180. package/src/plugins/builtin/chat-controller.ts +85 -14
  181. package/src/plugins/builtin/chat.test.tsx +283 -36
  182. package/src/plugins/builtin/chat.tsx +363 -170
  183. package/src/plugins/builtin/cloud-auth-actions.tsx +72 -0
  184. package/src/plugins/builtin/cloud-tweets.tsx +896 -0
  185. package/src/plugins/builtin/comparison-chart.test.tsx +46 -57
  186. package/src/plugins/builtin/comparison-chart.tsx +11 -11
  187. package/src/plugins/builtin/correlation/compute.test.ts +0 -17
  188. package/src/plugins/builtin/correlation/index.tsx +51 -46
  189. package/src/plugins/builtin/correlation/settings.ts +1 -2
  190. package/src/plugins/builtin/debug.tsx +99 -70
  191. package/src/plugins/builtin/earnings/earnings-cache.test.ts +2 -2
  192. package/src/plugins/builtin/earnings/earnings-cache.ts +12 -3
  193. package/src/plugins/builtin/earnings/earnings-data.test.ts +42 -11
  194. package/src/plugins/builtin/earnings/earnings-data.ts +70 -5
  195. package/src/plugins/builtin/earnings/index.tsx +187 -94
  196. package/src/plugins/builtin/econ/calendar-source.ts +9 -116
  197. package/src/plugins/builtin/econ/fred-client.test.ts +1 -14
  198. package/src/plugins/builtin/econ/fred-series-map.ts +1 -0
  199. package/src/plugins/builtin/econ/index.tsx +236 -421
  200. package/src/plugins/builtin/econ/types.ts +1 -0
  201. package/src/plugins/builtin/fx-matrix/index.tsx +43 -45
  202. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -0
  203. package/src/plugins/builtin/help.test.tsx +120 -60
  204. package/src/plugins/builtin/help.tsx +266 -82
  205. package/src/plugins/builtin/holders/index.test.ts +45 -0
  206. package/src/plugins/builtin/holders/index.tsx +949 -0
  207. package/src/plugins/builtin/insider/index.tsx +207 -253
  208. package/src/plugins/builtin/insider/insider-data.test.ts +1 -11
  209. package/src/plugins/builtin/insider/insider-data.ts +1 -0
  210. package/src/plugins/builtin/layout-manager.tsx +12 -40
  211. package/src/plugins/builtin/market-movers/index.tsx +70 -144
  212. package/src/plugins/builtin/market-movers/screener.test.ts +0 -18
  213. package/src/plugins/builtin/news-wire/breaking-notifications.test.ts +98 -0
  214. package/src/plugins/builtin/news-wire/breaking-notifications.ts +110 -0
  215. package/src/plugins/builtin/news-wire/breaking-pane.tsx +157 -0
  216. package/src/plugins/builtin/news-wire/categories.ts +114 -0
  217. package/src/plugins/builtin/news-wire/default-feeds.ts +3 -0
  218. package/src/plugins/builtin/news-wire/digest-store.ts +44 -0
  219. package/src/plugins/builtin/news-wire/feed-config.test.ts +90 -0
  220. package/src/plugins/builtin/news-wire/feed-config.ts +216 -0
  221. package/src/plugins/builtin/news-wire/feed-pane.tsx +21 -0
  222. package/src/plugins/builtin/news-wire/hash.ts +9 -0
  223. package/src/plugins/builtin/news-wire/index.tsx +92 -0
  224. package/src/plugins/builtin/news-wire/industry-pane.tsx +140 -0
  225. package/src/plugins/builtin/news-wire/news-detail-view.tsx +256 -0
  226. package/src/plugins/builtin/news-wire/news-footer.ts +28 -0
  227. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +94 -0
  228. package/src/plugins/builtin/news-wire/news-query-presets.ts +40 -0
  229. package/src/plugins/builtin/news-wire/news-table.test.tsx +206 -0
  230. package/src/plugins/builtin/news-wire/news-table.tsx +296 -0
  231. package/src/plugins/builtin/news-wire/persisted-articles.ts +92 -0
  232. package/src/plugins/builtin/news-wire/read-state.test.ts +30 -0
  233. package/src/plugins/builtin/news-wire/read-state.ts +76 -0
  234. package/src/plugins/builtin/news-wire/rss-parser.test.ts +184 -0
  235. package/src/plugins/builtin/news-wire/rss-parser.ts +213 -0
  236. package/src/plugins/builtin/news-wire/rss-source.test.ts +159 -0
  237. package/src/plugins/builtin/news-wire/rss-source.ts +181 -0
  238. package/src/plugins/builtin/news-wire/top-pane.tsx +21 -0
  239. package/src/plugins/builtin/news.tsx +98 -25
  240. package/src/plugins/builtin/notes-files.ts +51 -10
  241. package/src/plugins/builtin/notes.tsx +332 -131
  242. package/src/plugins/builtin/options.test.tsx +185 -0
  243. package/src/plugins/builtin/options.tsx +346 -206
  244. package/src/plugins/builtin/plugin-groups.ts +92 -0
  245. package/src/plugins/builtin/portfolio-list/header.tsx +72 -37
  246. package/src/plugins/builtin/portfolio-list/index.tsx +25 -42
  247. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  248. package/src/plugins/builtin/portfolio-list/pane.tsx +246 -164
  249. package/src/plugins/builtin/portfolio-list/settings.ts +19 -51
  250. package/src/plugins/builtin/portfolio-list/summary.tsx +21 -16
  251. package/src/plugins/builtin/portfolio-list/table.tsx +66 -25
  252. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +1 -1
  253. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +1 -1
  254. package/src/plugins/builtin/portfolio-list.test.ts +60 -1
  255. package/src/plugins/builtin/portfolio-list.test.tsx +122 -55
  256. package/src/plugins/builtin/quote-monitor.test.tsx +2 -27
  257. package/src/plugins/builtin/research/index.tsx +724 -0
  258. package/src/plugins/builtin/sec.tsx +67 -19
  259. package/src/plugins/builtin/sectors/index.tsx +42 -80
  260. package/src/plugins/builtin/sectors/sector-data.ts +1 -0
  261. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +25 -39
  262. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +108 -58
  263. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +383 -271
  264. package/src/plugins/builtin/ticker-detail/pane.tsx +135 -49
  265. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +43 -27
  266. package/src/plugins/builtin/ticker-detail/settings.ts +3 -6
  267. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +16 -10
  268. package/src/plugins/builtin/ticker-detail.test.tsx +156 -33
  269. package/src/plugins/builtin/ticker-surface.ts +68 -0
  270. package/src/plugins/builtin/world-indices/index.tsx +44 -82
  271. package/src/plugins/builtin/world-indices/indices.ts +1 -0
  272. package/src/plugins/builtin/yahoo.ts +55 -1
  273. package/src/plugins/builtin/yield-curve/index.tsx +87 -145
  274. package/src/plugins/builtin/yield-curve/treasury-data.ts +6 -77
  275. package/src/plugins/catalog-ui.test.ts +24 -0
  276. package/src/plugins/catalog-ui.ts +44 -0
  277. package/src/plugins/catalog.ts +2 -50
  278. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  279. package/src/plugins/ibkr/account-cache.test.ts +15 -14
  280. package/src/plugins/ibkr/account-cache.ts +15 -120
  281. package/src/plugins/ibkr/broker-adapter.ts +67 -0
  282. package/src/plugins/ibkr/config.test.ts +0 -18
  283. package/src/plugins/ibkr/dialogs.tsx +14 -56
  284. package/src/plugins/ibkr/flex.test.ts +57 -2
  285. package/src/plugins/ibkr/flex.ts +116 -12
  286. package/src/plugins/ibkr/gateway-helpers.ts +8 -3
  287. package/src/plugins/ibkr/gateway-service-native.ts +2381 -0
  288. package/src/plugins/ibkr/gateway-service.test.ts +47 -9
  289. package/src/plugins/ibkr/gateway-service.ts +264 -2235
  290. package/src/plugins/ibkr/gateway-types.ts +27 -0
  291. package/src/plugins/ibkr/index.test.tsx +3 -11
  292. package/src/plugins/ibkr/index.tsx +1 -0
  293. package/src/plugins/ibkr/price-normalization.test.ts +1 -6
  294. package/src/plugins/ibkr/price-normalization.ts +1 -0
  295. package/src/plugins/ibkr/trade-badge.tsx +6 -5
  296. package/src/plugins/ibkr/trade-tab.test.tsx +2 -2
  297. package/src/plugins/ibkr/trade-tab.tsx +164 -117
  298. package/src/plugins/ibkr/trading-pane.tsx +117 -83
  299. package/src/plugins/pane-manager.test.ts +3 -1
  300. package/src/plugins/pane-manager.ts +93 -19
  301. package/src/plugins/plugin-runtime.test.tsx +176 -74
  302. package/src/plugins/plugin-runtime.tsx +228 -10
  303. package/src/plugins/prediction-markets/cache.ts +49 -0
  304. package/src/plugins/prediction-markets/categories.ts +0 -10
  305. package/src/plugins/prediction-markets/chart.test.tsx +1 -1
  306. package/src/plugins/prediction-markets/chart.tsx +21 -20
  307. package/src/plugins/prediction-markets/cli.ts +1 -0
  308. package/src/plugins/prediction-markets/controller-data.ts +135 -89
  309. package/src/plugins/prediction-markets/controller.ts +37 -108
  310. package/src/plugins/prediction-markets/detail/book.tsx +23 -22
  311. package/src/plugins/prediction-markets/detail/outcomes.tsx +33 -32
  312. package/src/plugins/prediction-markets/detail/overview.tsx +17 -12
  313. package/src/plugins/prediction-markets/detail/pane.tsx +59 -45
  314. package/src/plugins/prediction-markets/detail/rules.tsx +22 -7
  315. package/src/plugins/prediction-markets/detail/shared.tsx +3 -2
  316. package/src/plugins/prediction-markets/detail/trades.tsx +12 -11
  317. package/src/plugins/prediction-markets/detail.test.tsx +1 -1
  318. package/src/plugins/prediction-markets/keyboard.ts +1 -0
  319. package/src/plugins/prediction-markets/launch.test.ts +1 -1
  320. package/src/plugins/prediction-markets/launch.ts +1 -1
  321. package/src/plugins/prediction-markets/metrics.ts +25 -12
  322. package/src/plugins/prediction-markets/pane.test.tsx +16 -7
  323. package/src/plugins/prediction-markets/pane.tsx +180 -82
  324. package/src/plugins/prediction-markets/plugin.test.tsx +37 -45
  325. package/src/plugins/prediction-markets/services/fetch.ts +27 -10
  326. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +20 -3
  327. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  328. package/src/plugins/prediction-markets/settings.ts +0 -5
  329. package/src/plugins/prediction-markets/test-helpers.tsx +19 -44
  330. package/src/plugins/registry.test.ts +307 -0
  331. package/src/plugins/registry.ts +293 -82
  332. package/src/react/input.ts +53 -0
  333. package/src/react/plugins/plugin-slot.tsx +21 -0
  334. package/src/react/use-raf-callback.ts +44 -0
  335. package/src/renderers/electrobun/bun/application-menu-click.test.ts +41 -0
  336. package/src/renderers/electrobun/bun/application-menu-click.ts +55 -0
  337. package/src/renderers/electrobun/bun/application-menu.ts +118 -0
  338. package/src/renderers/electrobun/bun/context-menu-click.test.ts +67 -0
  339. package/src/renderers/electrobun/bun/context-menu-click.ts +61 -0
  340. package/src/renderers/electrobun/bun/context-menu-normalize.test.ts +52 -0
  341. package/src/renderers/electrobun/bun/context-menu-normalize.ts +67 -0
  342. package/src/renderers/electrobun/bun/core-capabilities.ts +328 -0
  343. package/src/renderers/electrobun/bun/desktop-workspace.test.ts +63 -0
  344. package/src/renderers/electrobun/bun/desktop-workspace.ts +162 -0
  345. package/src/renderers/electrobun/bun/index.ts +1149 -0
  346. package/src/renderers/electrobun/bun/window-frame.test.ts +40 -0
  347. package/src/renderers/electrobun/bun/window-frame.ts +46 -0
  348. package/src/renderers/electrobun/shared/protocol.ts +72 -0
  349. package/src/renderers/electrobun/view/ai-host.ts +93 -0
  350. package/src/renderers/electrobun/view/app-services.ts +348 -0
  351. package/src/renderers/electrobun/view/application-menu-bridge.ts +12 -0
  352. package/src/renderers/electrobun/view/backend-rpc.ts +173 -0
  353. package/src/renderers/electrobun/view/broker-remote-client.ts +154 -0
  354. package/src/renderers/electrobun/view/config-host.ts +35 -0
  355. package/src/renderers/electrobun/view/context-menu.test.ts +140 -0
  356. package/src/renderers/electrobun/view/context-menu.ts +164 -0
  357. package/src/renderers/electrobun/view/data-table.tsx +773 -0
  358. package/src/renderers/electrobun/view/desktop-controls.tsx +651 -0
  359. package/src/renderers/electrobun/view/desktop-window-bridge.ts +59 -0
  360. package/src/renderers/electrobun/view/desktop-window-snapshot.test.ts +57 -0
  361. package/src/renderers/electrobun/view/desktop-window-snapshot.ts +41 -0
  362. package/src/renderers/electrobun/view/dialog-host.tsx +62 -0
  363. package/src/renderers/electrobun/view/http-fetch.ts +113 -0
  364. package/src/renderers/electrobun/view/input-host.tsx +65 -0
  365. package/src/renderers/electrobun/view/key-event.test.ts +34 -0
  366. package/src/renderers/electrobun/view/key-event.ts +134 -0
  367. package/src/renderers/electrobun/view/main.tsx +104 -0
  368. package/src/renderers/electrobun/view/native-renderer.ts +110 -0
  369. package/src/renderers/electrobun/view/native-stubs/chart-kitty-support.ts +9 -0
  370. package/src/renderers/electrobun/view/native-stubs/chart-surface-manager.ts +25 -0
  371. package/src/renderers/electrobun/view/native-stubs/chart-surface-sync.ts +1 -0
  372. package/src/renderers/electrobun/view/notes-files.ts +56 -0
  373. package/src/renderers/electrobun/view/resource-store.test.ts +44 -0
  374. package/src/renderers/electrobun/view/resource-store.ts +106 -0
  375. package/src/renderers/electrobun/view/rpc-codec.ts +42 -0
  376. package/src/renderers/electrobun/view/scrollbar-activity.ts +23 -0
  377. package/src/renderers/electrobun/view/toast-host.tsx +65 -0
  378. package/src/renderers/electrobun/view/ui-host.tsx +1490 -0
  379. package/src/renderers/electrobun/view/update-host.ts +38 -0
  380. package/src/renderers/opentui/dialog-host.test.tsx +44 -0
  381. package/src/renderers/opentui/dialog-host.tsx +56 -0
  382. package/src/renderers/opentui/host.tsx +134 -0
  383. package/src/renderers/opentui/input-host.tsx +24 -0
  384. package/src/renderers/opentui/start.tsx +71 -0
  385. package/src/renderers/opentui/test-utils.tsx +76 -0
  386. package/src/renderers/opentui/toast-host.tsx +10 -0
  387. package/src/renderers/opentui/ui-host.tsx +97 -0
  388. package/src/sources/gloomberb-cloud.test.ts +288 -17
  389. package/src/sources/gloomberb-cloud.ts +176 -9
  390. package/src/sources/provider-errors.ts +1 -0
  391. package/src/sources/provider-router.test.ts +220 -81
  392. package/src/sources/provider-router.ts +294 -111
  393. package/src/sources/sec-edgar.ts +19 -6
  394. package/src/sources/yahoo-finance.test.ts +0 -5
  395. package/src/sources/yahoo-finance.ts +420 -11
  396. package/src/state/app-activity.ts +7 -7
  397. package/src/state/app-bootstrap.ts +114 -40
  398. package/src/state/app-context-selector.test.tsx +70 -1
  399. package/src/state/app-context.test.ts +71 -3
  400. package/src/state/app-context.test.tsx +14 -7
  401. package/src/state/app-context.tsx +205 -869
  402. package/src/state/config-save-scheduler.ts +35 -0
  403. package/src/state/persist-scheduler.test.ts +67 -0
  404. package/src/state/persist-scheduler.ts +69 -0
  405. package/src/state/selectors-ui.ts +55 -0
  406. package/src/state/selectors.ts +15 -71
  407. package/src/state/session-persistence.test.ts +1 -1
  408. package/src/state/ticker-refresh-queue.ts +52 -3
  409. package/src/state/use-inline-tickers.ts +18 -16
  410. package/src/state/use-quote-streaming.test.ts +0 -6
  411. package/src/state/use-quote-streaming.test.tsx +1 -1
  412. package/src/state/use-quote-streaming.ts +3 -2
  413. package/src/state/use-session-snapshot.ts +32 -17
  414. package/src/test-support/plugin-runtime.ts +149 -0
  415. package/src/theme/color-utils.ts +1 -0
  416. package/src/theme/colors.ts +16 -0
  417. package/src/types/broker.ts +16 -1
  418. package/src/types/capability-route-source.ts +23 -0
  419. package/src/types/config.test.ts +23 -0
  420. package/src/types/config.ts +145 -10
  421. package/src/types/context-menu.ts +95 -0
  422. package/src/types/data-provider.ts +34 -3
  423. package/src/types/desktop-menu.ts +13 -0
  424. package/src/types/desktop-window.ts +29 -0
  425. package/src/types/financials.ts +123 -2
  426. package/src/types/instrument.ts +1 -0
  427. package/src/types/news-source.ts +9 -0
  428. package/src/types/persistence.ts +1 -0
  429. package/src/types/plugin.ts +32 -8
  430. package/src/ui/ascii-font.ts +85 -0
  431. package/src/ui/context-menu.test.ts +94 -0
  432. package/src/ui/context-menu.tsx +433 -0
  433. package/src/ui/dialog.tsx +55 -0
  434. package/src/ui/host.tsx +361 -0
  435. package/src/ui/index.tsx +126 -0
  436. package/src/ui/toast.tsx +40 -0
  437. package/src/updater.test.ts +69 -0
  438. package/src/updater.ts +61 -18
  439. package/src/utils/api-client.test.ts +99 -9
  440. package/src/utils/api-client.ts +378 -18
  441. package/src/utils/back-navigation.ts +6 -0
  442. package/src/utils/cli-output.ts +1 -0
  443. package/src/utils/currency-units.test.ts +1 -6
  444. package/src/utils/currency-units.ts +1 -0
  445. package/src/utils/debug-log.ts +68 -0
  446. package/src/utils/dom-screenshot.ts +326 -0
  447. package/src/utils/exchange-rate-map.ts +1 -0
  448. package/src/utils/exchanges.ts +214 -0
  449. package/src/utils/format.test.ts +7 -0
  450. package/src/utils/format.ts +1 -0
  451. package/src/utils/fuzzy-search.ts +1 -0
  452. package/src/utils/html-entities.test.ts +8 -0
  453. package/src/utils/html-entities.ts +32 -0
  454. package/src/utils/http-transport.ts +11 -0
  455. package/src/utils/main-thread-monitor.ts +127 -0
  456. package/src/utils/perf-marks.ts +54 -0
  457. package/src/utils/quote-freshness.ts +27 -50
  458. package/src/utils/quote-resolution.test.ts +5 -5
  459. package/src/utils/quote-resolution.ts +0 -1
  460. package/src/utils/quote-time.ts +1 -5
  461. package/src/utils/sec.test.ts +0 -12
  462. package/src/utils/selection-clipboard.ts +14 -6
  463. package/src/utils/shortcut-labels.test.ts +20 -0
  464. package/src/utils/shortcut-labels.ts +67 -0
  465. package/src/utils/slugify.ts +1 -0
  466. package/src/utils/terminal-input-reset.ts +1 -0
  467. package/src/utils/text-wrap.ts +82 -0
  468. package/src/utils/throttled-fetch.test.ts +19 -0
  469. package/src/utils/throttled-fetch.ts +11 -9
  470. package/src/utils/ticker-list.test.ts +1 -7
  471. package/src/utils/ticker-list.ts +1 -0
  472. package/src/utils/timestamp.ts +29 -0
  473. package/src/utils/tweet-text.test.ts +13 -0
  474. package/src/utils/tweet-text.ts +18 -0
  475. package/src/utils/url.ts +11 -0
  476. package/src/version.ts +1 -1
  477. package/src/components/chart/chart-control-hint.tsx +0 -39
  478. package/src/components/detail-feed-view.test.tsx +0 -180
  479. package/src/components/detail-feed-view.tsx +0 -531
  480. package/src/components/layout/pixel-mouse-mode.test.ts +0 -122
  481. package/src/components/layout/pixel-mouse.test.ts +0 -40
  482. package/src/components/spinner.tsx +0 -2
  483. package/src/components/tab-bar.tsx +0 -17
  484. package/src/components/ui/multi-select-chips.tsx +0 -69
  485. package/src/plugins/builtin/ai/index.test.tsx +0 -139
  486. package/src/plugins/builtin/ask-ai.tsx +0 -9
  487. package/src/plugins/builtin/correlation/plugin.test.ts +0 -44
  488. package/src/plugins/builtin/correlation/settings.test.ts +0 -34
  489. package/src/plugins/builtin/econ/calendar-source.test.ts +0 -219
  490. package/src/plugins/builtin/econ/fred-client.ts +0 -79
  491. package/src/plugins/builtin/fred-settings.ts +0 -10
  492. package/src/plugins/builtin/options-availability.test.tsx +0 -179
  493. package/src/plugins/builtin/options-availability.ts +0 -158
  494. package/src/plugins/builtin/portfolio-list/metrics.test.ts +0 -61
  495. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +0 -212
  496. package/src/plugins/prediction-markets/status.ts +0 -24
  497. package/src/plugins/prediction-markets/table.tsx +0 -87
  498. package/src/utils/quote-time.test.ts +0 -22
  499. package/src/utils/selection-clipboard.test.ts +0 -43
  500. package/src/utils/terminal-input-reset.test.ts +0 -50
@@ -1,7 +1,10 @@
1
+ import { AsciiText, Box, Text, compactContextMenuItems, useContextMenu, useUiHost } from "../../ui";
1
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
3
- import { useDialogState } from "@opentui-ui/dialog/react";
4
- import { saveConfig } from "../../data/config-store";
3
+ import { useNativeRenderer, useRendererHost, useUiCapabilities } from "../../ui";
4
+ import { useShortcut, useViewport, type KeyEventLike } from "../../react/input";
5
+ import { useDialogState } from "../../ui/dialog";
6
+ import { scheduleConfigSave } from "../../state/config-save-scheduler";
7
+ import type { DesktopDockPreviewState, DesktopWindowBridge } from "../../types/desktop-window";
5
8
  import {
6
9
  MIN_FLOAT_HEIGHT,
7
10
  MIN_FLOAT_WIDTH,
@@ -12,6 +15,7 @@ import {
12
15
  getDockDividerLayouts,
13
16
  getDockLeafLayouts,
14
17
  getRememberedFloatingRect,
18
+ gridlockAllPanes,
15
19
  isPaneInLayout,
16
20
  removePane,
17
21
  resizeSplitAtPath,
@@ -28,21 +32,39 @@ import {
28
32
  } from "../../plugins/pane-manager";
29
33
  import type { PluginRegistry } from "../../plugins/registry";
30
34
  import { removePaneInstances, type LayoutConfig } from "../../types/config";
35
+ import { contextMenuDivider, type ContextMenuItem } from "../../types/context-menu";
31
36
  import {
32
- PaneInstanceProvider,
33
- resolveCollectionForPane,
34
37
  resolveTickerForPane,
35
- useAppState,
38
+ useAppDispatch,
39
+ useAppSelector,
36
40
  } from "../../state/app-context";
41
+ import {
42
+ selectCommandBarOpen,
43
+ selectFocusedPaneId,
44
+ selectLayout,
45
+ selectStatusBarVisible,
46
+ } from "../../state/selectors-ui";
37
47
  import { colors } from "../../theme/colors";
38
48
  import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
39
49
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
40
50
  import { FloatingPaneWrapper } from "./floating-pane";
51
+ import { PaneContent } from "./pane-content";
41
52
  import { PaneWrapper } from "./pane";
42
- import { getPaneBodyHeight, getPaneBodyWidth } from "./pane-sizing";
53
+ import { hasPaneFooterContent, PaneFooterProvider } from "./pane-footer";
54
+ import { getPaneBodyHeight, getPaneBodyWidth, shouldReservePaneFooter } from "./pane-sizing";
55
+ import { getPaneDisplayTitle } from "./pane-title";
56
+ import { TITLEBAR_OVERLAY_HEIGHT_PX } from "./titlebar-overlay";
57
+ import { capturePaneScreenshotPngBase64 } from "../../utils/dom-screenshot";
58
+ import {
59
+ formatPlatformShortcutLabel,
60
+ getShortcutDisplayMode,
61
+ type ShortcutDisplayMode,
62
+ } from "../../utils/shortcut-labels";
43
63
 
44
64
  interface ShellProps {
45
65
  pluginRegistry: PluginRegistry;
66
+ desktopWindowBridge?: DesktopWindowBridge;
67
+ desktopDockPreview?: DesktopDockPreviewState | null;
46
68
  }
47
69
 
48
70
  interface HoverOverlay {
@@ -83,7 +105,8 @@ interface ActionMenuState {
83
105
  paneId: string;
84
106
  x: number;
85
107
  y: number;
86
- items: Array<{ id: string; label: string; action: () => void }>;
108
+ width: number;
109
+ items: Array<{ id: string; label: string; accelerator?: string; action: () => void }>;
87
110
  }
88
111
 
89
112
  interface FloatingPreviewRect {
@@ -114,6 +137,13 @@ interface PaneDragReleaseResult {
114
137
  shouldShowGridlockTip: boolean;
115
138
  }
116
139
 
140
+ export interface PaneDragRectState {
141
+ mode: "docked" | "floating";
142
+ startX: number;
143
+ startY: number;
144
+ origRect: FloatingRect;
145
+ }
146
+
117
147
  type DragMode =
118
148
  | {
119
149
  type: "divider";
@@ -127,11 +157,7 @@ type DragMode =
127
157
  | {
128
158
  type: "pane-drag";
129
159
  paneId: string;
130
- mode: "docked" | "floating";
131
- startX: number;
132
- startY: number;
133
- origRect: FloatingRect;
134
- }
160
+ } & PaneDragRectState
135
161
  | {
136
162
  type: "float-resize";
137
163
  paneId: string;
@@ -140,35 +166,157 @@ type DragMode =
140
166
  origRect: FloatingRect;
141
167
  };
142
168
 
143
- const HEADER_HEIGHT = 1;
144
- const MENU_WIDTH = 18;
169
+ interface ShellMouseEvent {
170
+ type: string;
171
+ x: number;
172
+ y: number;
173
+ button?: number;
174
+ preciseX?: number;
175
+ preciseY?: number;
176
+ stopPropagation: () => void;
177
+ preventDefault: () => void;
178
+ }
179
+
180
+ const DEFAULT_HEADER_HEIGHT = 1;
181
+ const MENU_MIN_WIDTH = 18;
182
+ const MENU_MAX_WIDTH = 44;
183
+ const MENU_Z_INDEX = 10_000;
145
184
  const PANE_DRAG_THRESHOLD = 2;
185
+ const PRECISE_PANE_DRAG_THRESHOLD = 0.15;
186
+ const PANE_MANAGEMENT_ACCELERATORS = {
187
+ settings: "CmdOrCtrl+,",
188
+ toggleFloating: "CmdOrCtrl+Shift+D",
189
+ popOut: "CmdOrCtrl+Shift+O",
190
+ copyScreenshot: "CmdOrCtrl+Shift+C",
191
+ close: "CmdOrCtrl+W",
192
+ layoutActions: "CmdOrCtrl+Shift+L",
193
+ gridlockAll: "CmdOrCtrl+Shift+G",
194
+ } as const;
195
+
196
+ type PaneManagementShortcut =
197
+ | "settings"
198
+ | "toggle-floating"
199
+ | "pop-out"
200
+ | "copy-screenshot"
201
+ | "close"
202
+ | "layout-actions"
203
+ | "gridlock-all";
204
+
205
+ export function resolvePaneManagementShortcut(
206
+ event: Pick<KeyEventLike, "name" | "key" | "ctrl" | "meta" | "super" | "shift" | "alt">,
207
+ ): PaneManagementShortcut | null {
208
+ if (!event.ctrl && !event.meta && !event.super) return null;
209
+ const name = (event.name ?? event.key ?? "").toLowerCase();
210
+ if (name === "w") return "close";
211
+ if (!event.shift && name === ",") return "settings";
212
+ if (!event.shift || event.alt) return null;
213
+ if (name === "c") return "copy-screenshot";
214
+ if (name === "d") return "toggle-floating";
215
+ if (name === "o") return "pop-out";
216
+ if (name === "l") return "layout-actions";
217
+ if (name === "g") return "gridlock-all";
218
+ return null;
219
+ }
220
+
221
+ export function resolveAppHeaderHeightCells(options: { titleBarOverlay?: boolean; cellHeightPx?: number }): number {
222
+ if (!options.titleBarOverlay || !options.cellHeightPx || options.cellHeightPx <= 0) return DEFAULT_HEADER_HEIGHT;
223
+ return TITLEBAR_OVERLAY_HEIGHT_PX / options.cellHeightPx;
224
+ }
146
225
 
147
226
  function pointInRect(rect: LayoutBounds, x: number, y: number): boolean {
148
227
  return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
149
228
  }
150
229
 
151
- function isMeaningfulPaneDrag(startX: number, startY: number, currentX: number, currentY: number): boolean {
152
- return Math.max(Math.abs(currentX - startX), Math.abs(currentY - startY)) >= PANE_DRAG_THRESHOLD;
230
+ function isMeaningfulPaneDrag(startX: number, startY: number, currentX: number, currentY: number, threshold = PANE_DRAG_THRESHOLD): boolean {
231
+ return Math.max(Math.abs(currentX - startX), Math.abs(currentY - startY)) >= threshold;
153
232
  }
154
233
 
155
- function positionFloatingRectUnderPointer(
234
+ function clampFinite(value: number, min: number, max: number, fallback = min): number {
235
+ const normalized = Number.isFinite(value) ? value : fallback;
236
+ return Math.max(min, Math.min(max, normalized));
237
+ }
238
+
239
+ function fitFloatingDimension(value: number, min: number, available: number): number {
240
+ const max = Math.max(1, Number.isFinite(available) ? available : 1);
241
+ const boundedMin = Math.min(min, max);
242
+ return clampFinite(value, boundedMin, max, boundedMin);
243
+ }
244
+
245
+ export function constrainFloatingRectToBounds(
156
246
  rect: FloatingRect,
157
- drag: Extract<DragMode, { type: "pane-drag" }>,
158
- pointerX: number,
159
- pointerY: number,
160
247
  totalWidth: number,
161
248
  totalHeight: number,
162
249
  ): FloatingRect {
163
- const pointerOffsetX = Math.max(0, Math.min(rect.width - 1, drag.startX - drag.origRect.x));
164
- const pointerOffsetY = Math.max(0, Math.min(rect.height - 1, drag.startY - drag.origRect.y));
250
+ const boundsWidth = Math.max(1, Number.isFinite(totalWidth) ? totalWidth : 1);
251
+ const boundsHeight = Math.max(1, Number.isFinite(totalHeight) ? totalHeight : 1);
252
+ const width = fitFloatingDimension(rect.width, MIN_FLOAT_WIDTH, boundsWidth);
253
+ const height = fitFloatingDimension(rect.height, MIN_FLOAT_HEIGHT, boundsHeight);
254
+ const maxX = Math.max(0, boundsWidth - width);
255
+ const maxY = Math.max(0, boundsHeight - height);
256
+
165
257
  return {
166
258
  ...rect,
167
- x: Math.max(0, Math.min(totalWidth - rect.width, pointerX - pointerOffsetX)),
168
- y: Math.max(0, Math.min(totalHeight - rect.height, pointerY - pointerOffsetY)),
259
+ x: clampFinite(rect.x, 0, maxX, 0),
260
+ y: clampFinite(rect.y, 0, maxY, 0),
261
+ width,
262
+ height,
169
263
  };
170
264
  }
171
265
 
266
+ function positionFloatingRectUnderPointer(
267
+ rect: FloatingRect,
268
+ drag: PaneDragRectState,
269
+ pointerX: number,
270
+ pointerY: number,
271
+ totalWidth: number,
272
+ totalHeight: number,
273
+ ): FloatingRect {
274
+ const fittedRect = constrainFloatingRectToBounds(rect, totalWidth, totalHeight);
275
+ const pointerOffsetX = Math.max(0, Math.min(fittedRect.width - 1, drag.startX - drag.origRect.x));
276
+ const pointerOffsetY = Math.max(0, Math.min(fittedRect.height - 1, drag.startY - drag.origRect.y));
277
+ return constrainFloatingRectToBounds({
278
+ ...fittedRect,
279
+ x: pointerX - pointerOffsetX,
280
+ y: pointerY - pointerOffsetY,
281
+ }, totalWidth, totalHeight);
282
+ }
283
+
284
+ export function resolvePaneDragFloatingRect(
285
+ drag: PaneDragRectState,
286
+ baseRect: FloatingRect,
287
+ pointerX: number,
288
+ pointerY: number,
289
+ totalWidth: number,
290
+ totalHeight: number,
291
+ ): FloatingRect {
292
+ if (drag.mode === "docked") {
293
+ return positionFloatingRectUnderPointer(baseRect, drag, pointerX, pointerY, totalWidth, totalHeight);
294
+ }
295
+
296
+ return constrainFloatingRectToBounds({
297
+ x: drag.origRect.x + (pointerX - drag.startX),
298
+ y: drag.origRect.y + (pointerY - drag.startY),
299
+ width: drag.origRect.width,
300
+ height: drag.origRect.height,
301
+ }, totalWidth, totalHeight);
302
+ }
303
+
304
+ function resolveFloatResizeRect(
305
+ drag: Extract<DragMode, { type: "float-resize" }>,
306
+ pointerX: number,
307
+ pointerY: number,
308
+ totalWidth: number,
309
+ totalHeight: number,
310
+ ): FloatingRect {
311
+ return constrainFloatingRectToBounds({
312
+ x: drag.origRect.x,
313
+ y: drag.origRect.y,
314
+ width: Math.max(MIN_FLOAT_WIDTH, Math.min(totalWidth - drag.origRect.x, drag.origRect.width + (pointerX - drag.startX))),
315
+ height: Math.max(MIN_FLOAT_HEIGHT, Math.min(totalHeight - drag.origRect.y, drag.origRect.height + (pointerY - drag.startY))),
316
+ zIndex: drag.origRect.zIndex,
317
+ }, totalWidth, totalHeight);
318
+ }
319
+
172
320
  function centerRectWithin(parent: LayoutBounds, width: number, height: number): LayoutBounds {
173
321
  const nextWidth = Math.max(1, Math.min(parent.width, width));
174
322
  const nextHeight = Math.max(1, Math.min(parent.height, height));
@@ -248,6 +396,7 @@ export function buildNativeWindowState(
248
396
  overlay: { open: boolean; width: number; contentHeight: number },
249
397
  transientOccluders: readonly NativeTransientOccluder[] = [],
250
398
  dockDividers: readonly DockDividerLayout[] = [],
399
+ appHeaderHeight: number = DEFAULT_HEADER_HEIGHT,
251
400
  ): { paneLayers: NativePaneLayer[]; occluders: NativeOccluder[] } {
252
401
  const previewedFloatingPanes = floatingPanes.map((pane) => (
253
402
  dragFloatingRect?.paneId === pane.paneId
@@ -265,7 +414,7 @@ export function buildNativeWindowState(
265
414
  paneId: pane.paneId,
266
415
  rect: {
267
416
  x: pane.rect.x,
268
- y: pane.rect.y + HEADER_HEIGHT,
417
+ y: pane.rect.y + appHeaderHeight,
269
418
  width: pane.rect.width,
270
419
  height: pane.rect.height,
271
420
  },
@@ -278,7 +427,7 @@ export function buildNativeWindowState(
278
427
  paneId: null,
279
428
  rect: {
280
429
  x: divider.rect.x,
281
- y: divider.rect.y + HEADER_HEIGHT,
430
+ y: divider.rect.y + appHeaderHeight,
282
431
  width: divider.rect.width,
283
432
  height: divider.rect.height,
284
433
  },
@@ -293,7 +442,7 @@ export function buildNativeWindowState(
293
442
  paneId: null,
294
443
  rect: {
295
444
  x: occluder.rect.x,
296
- y: occluder.rect.y + HEADER_HEIGHT,
445
+ y: occluder.rect.y + appHeaderHeight,
297
446
  width: occluder.rect.width,
298
447
  height: occluder.rect.height,
299
448
  },
@@ -307,7 +456,7 @@ export function buildNativeWindowState(
307
456
  paneId: null,
308
457
  rect: {
309
458
  x: 0,
310
- y: HEADER_HEIGHT,
459
+ y: appHeaderHeight,
311
460
  width: overlay.width,
312
461
  height: overlay.contentHeight,
313
462
  },
@@ -362,7 +511,7 @@ function makeSnapGuides(width: number, height: number): SnapGuide[] {
362
511
  const cornerWidth = Math.max(8, Math.min(16, Math.floor(width * 0.2)));
363
512
  const cornerHeight = Math.max(4, Math.min(8, Math.floor(height * 0.22)));
364
513
  const edgeWidth = Math.max(6, Math.min(10, Math.floor(width * 0.1)));
365
- const edgeHeight = Math.max(3, Math.min(6, Math.floor(height * 0.14)));
514
+ const topBottomEdgeHeight = Math.max(2, Math.min(4, Math.floor(height * 0.1)));
366
515
 
367
516
  return [
368
517
  {
@@ -412,16 +561,16 @@ function makeSnapGuides(width: number, height: number): SnapGuide[] {
412
561
  },
413
562
  {
414
563
  position: "top",
415
- triggerRect: { x: cornerWidth, y: 0, width: Math.max(1, width - (cornerWidth * 2)), height: edgeHeight },
564
+ triggerRect: { x: cornerWidth, y: 0, width: Math.max(1, width - (cornerWidth * 2)), height: topBottomEdgeHeight },
416
565
  previewRect: { x: 0, y: 0, width, height: halfHeight },
417
566
  },
418
567
  {
419
568
  position: "bottom",
420
569
  triggerRect: {
421
570
  x: cornerWidth,
422
- y: Math.max(0, height - edgeHeight),
571
+ y: Math.max(0, height - topBottomEdgeHeight),
423
572
  width: Math.max(1, width - (cornerWidth * 2)),
424
- height: edgeHeight,
573
+ height: topBottomEdgeHeight,
425
574
  },
426
575
  previewRect: { x: 0, y: Math.max(0, height - halfHeight), width, height: halfHeight },
427
576
  },
@@ -463,21 +612,33 @@ function menuForPane(
463
612
  persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
464
613
  focusPane: (paneId: string) => void,
465
614
  openPaneSettings: (paneId: string) => void,
466
- ) {
467
- const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
615
+ desktopWindowBridge?: DesktopWindowBridge,
616
+ copyPaneScreenshot?: (paneId: string) => void | Promise<void>,
617
+ ): ContextMenuItem[] {
618
+ const baseActions: ContextMenuItem[] = [];
468
619
  if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
469
620
  baseActions.push({
470
621
  id: "settings",
471
622
  label: "Settings",
472
- action: () => openPaneSettings(pane.instance.instanceId),
623
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.settings,
624
+ onSelect: () => openPaneSettings(pane.instance.instanceId),
625
+ });
626
+ }
627
+ if (copyPaneScreenshot) {
628
+ baseActions.push({
629
+ id: "copy-screenshot",
630
+ label: "Copy Screenshot",
631
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.copyScreenshot,
632
+ onSelect: () => copyPaneScreenshot(pane.instance.instanceId),
473
633
  });
474
634
  }
475
635
 
476
636
  if (pane.floating) {
477
637
  baseActions.push({
478
638
  id: "dock",
479
- label: "Dock",
480
- action: () => {
639
+ label: "Dock Pane",
640
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.toggleFloating,
641
+ onSelect: () => {
481
642
  persistLayout(applyDrop(layout, pane.instance.instanceId, { kind: "frame", edge: "right" }));
482
643
  focusPane(pane.instance.instanceId);
483
644
  },
@@ -485,59 +646,195 @@ function menuForPane(
485
646
  } else {
486
647
  baseActions.push({
487
648
  id: "float",
488
- label: "Float",
489
- action: () => {
649
+ label: "Float Pane",
650
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.toggleFloating,
651
+ onSelect: () => {
490
652
  persistLayout(floatPane(layout, pane.instance.instanceId, width, contentHeight, pane.def));
491
653
  focusPane(pane.instance.instanceId);
492
654
  },
493
655
  });
494
656
  }
495
657
 
658
+ if (desktopWindowBridge?.kind === "main" && desktopWindowBridge.popOutPane) {
659
+ baseActions.push({
660
+ id: "pop-out",
661
+ label: "Pop Out",
662
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.popOut,
663
+ onSelect: () => {
664
+ void desktopWindowBridge.popOutPane?.(pane.instance.instanceId);
665
+ },
666
+ });
667
+ }
668
+
669
+ baseActions.push({
670
+ id: "close-pane",
671
+ label: "Close Pane",
672
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.close,
673
+ onSelect: () => persistLayout(removePane(layout, pane.instance.instanceId)),
674
+ });
675
+
676
+ baseActions.push(
677
+ contextMenuDivider("pane:layout-divider"),
678
+ {
679
+ id: "layout-actions",
680
+ label: "Layout Actions...",
681
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.layoutActions,
682
+ onSelect: () => pluginRegistry.openCommandBar("LAY "),
683
+ },
684
+ {
685
+ id: "gridlock-all",
686
+ label: "Gridlock All Windows",
687
+ accelerator: PANE_MANAGEMENT_ACCELERATORS.gridlockAll,
688
+ onSelect: () => {
689
+ persistLayout(gridlockAllPanes(layout, { x: 0, y: 0, width, height: contentHeight }));
690
+ },
691
+ },
692
+ );
693
+
496
694
  return baseActions;
497
695
  }
498
696
 
499
- export function Shell({ pluginRegistry }: ShellProps) {
500
- const { state, dispatch } = useAppState();
501
- const renderer = useRenderer();
502
- const { width, height } = useTerminalDimensions();
697
+ function menuItemsForFallback(
698
+ items: ContextMenuItem[],
699
+ shortcutDisplayMode: ShortcutDisplayMode,
700
+ ): Array<{ id: string; label: string; accelerator?: string; action: () => void }> {
701
+ return items.flatMap((item) => {
702
+ if (item.type === "divider" || item.type === "role" || item.enabled === false || item.hidden === true) return [];
703
+ if (!item.onSelect) return [];
704
+ return [{
705
+ id: item.id,
706
+ label: item.label,
707
+ accelerator: item.accelerator
708
+ ? formatPlatformShortcutLabel(item.accelerator, undefined, shortcutDisplayMode)
709
+ : undefined,
710
+ action: () => { void item.onSelect?.(); },
711
+ }];
712
+ });
713
+ }
714
+
715
+ function actionMenuWidth(
716
+ items: Array<{ label: string; accelerator?: string }>,
717
+ availableWidth: number,
718
+ ): number {
719
+ const requested = Math.max(
720
+ MENU_MIN_WIDTH,
721
+ ...items.map((item) => item.label.length + (item.accelerator ? item.accelerator.length + 3 : 0) + 2),
722
+ );
723
+ return Math.max(MENU_MIN_WIDTH, Math.min(MENU_MAX_WIDTH, availableWidth, requested));
724
+ }
725
+
726
+ function truncateMenuText(text: string, width: number): string {
727
+ if (width <= 0) return "";
728
+ if (text.length <= width) return text;
729
+ if (width <= 3) return ".".repeat(width);
730
+ return `${text.slice(0, width - 3)}...`;
731
+ }
732
+
733
+ function resolveExternalDockPreview(
734
+ preview: DesktopDockPreviewState | null | undefined,
735
+ bounds: LayoutBounds,
736
+ ): DragPreview | null {
737
+ if (!preview?.paneId || !preview.edge) return null;
738
+
739
+ switch (preview.edge) {
740
+ case "left":
741
+ return {
742
+ kind: "dock",
743
+ target: { kind: "frame", edge: "left" },
744
+ rect: { x: bounds.x, y: bounds.y, width: Math.max(1, Math.floor(bounds.width / 2)), height: bounds.height },
745
+ };
746
+ case "right": {
747
+ const width = Math.max(1, Math.floor(bounds.width / 2));
748
+ return {
749
+ kind: "dock",
750
+ target: { kind: "frame", edge: "right" },
751
+ rect: { x: bounds.x + Math.max(0, bounds.width - width), y: bounds.y, width, height: bounds.height },
752
+ };
753
+ }
754
+ case "top":
755
+ return {
756
+ kind: "dock",
757
+ target: { kind: "frame", edge: "top" },
758
+ rect: { x: bounds.x, y: bounds.y, width: bounds.width, height: Math.max(1, Math.floor(bounds.height / 2)) },
759
+ };
760
+ case "bottom": {
761
+ const height = Math.max(1, Math.floor(bounds.height / 2));
762
+ return {
763
+ kind: "dock",
764
+ target: { kind: "frame", edge: "bottom" },
765
+ rect: { x: bounds.x, y: bounds.y + Math.max(0, bounds.height - height), width: bounds.width, height },
766
+ };
767
+ }
768
+ default:
769
+ return null;
770
+ }
771
+ }
772
+
773
+ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview }: ShellProps) {
774
+ const dispatch = useAppDispatch();
775
+ const config = useAppSelector((state) => state.config);
776
+ const paneState = useAppSelector((state) => state.paneState);
777
+ const focusedPaneId = useAppSelector(selectFocusedPaneId);
778
+ const commandBarOpen = useAppSelector(selectCommandBarOpen);
779
+ const inputCaptured = useAppSelector((state) => state.inputCaptured);
780
+ const statusBarVisible = useAppSelector(selectStatusBarVisible);
781
+ const renderer = useNativeRenderer();
782
+ const rendererHost = useRendererHost();
783
+ const uiKind = useUiHost().kind;
784
+ const shortcutDisplayMode = getShortcutDisplayMode(uiKind);
785
+ const { nativePaneChrome, nativeContextMenu, precisePointer, titleBarOverlay, cellHeightPx } = useUiCapabilities();
786
+ const { showContextMenu } = useContextMenu();
787
+ const { width, height } = useViewport();
503
788
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
504
789
 
505
- const contentHeight = height - (state.statusBarVisible ? 2 : 1);
790
+ const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
791
+ const contentHeight = Math.max(1, height - appHeaderHeight - (statusBarVisible ? 1 : 0));
506
792
  pluginRegistry.getTermSizeFn = () => ({ width, height: contentHeight });
507
793
 
508
- const layout = state.config.layout;
794
+ const layout = useAppSelector(selectLayout);
509
795
  const dialogOpen = useDialogState((dialog) => dialog.isOpen);
510
796
  const [hoveredPaneId, setHoveredPaneId] = useState<string | null>(null);
797
+ const setHoveredPaneIfChanged = useCallback((paneId: string | null) => {
798
+ setHoveredPaneId((current) => (current === paneId ? current : paneId));
799
+ }, []);
511
800
  const [menuState, setMenuState] = useState<ActionMenuState | null>(null);
512
- const overlayOpen = state.commandBarOpen || dialogOpen || !!menuState;
801
+ const [hoveredMenuItemId, setHoveredMenuItemId] = useState<string | null>(null);
802
+ const overlayOpen = commandBarOpen || dialogOpen || !!menuState;
513
803
 
514
804
  const dragRef = useRef<DragMode | null>(null);
515
805
  const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
516
806
  const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
517
807
  const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
518
808
  const [dockPreview, setDockPreview] = useState<DragPreview | null>(null);
809
+ const dividerPreviewRef = useRef<DividerPreviewState | null>(null);
810
+ const dockPreviewRef = useRef<DragPreview | null>(null);
811
+
812
+ const updateDragFloatingRect = useCallback((next: { paneId: string; rect: FloatingRect } | null) => {
813
+ setDragFloatingRect(next
814
+ ? { paneId: next.paneId, rect: constrainFloatingRectToBounds(next.rect, width, contentHeight) }
815
+ : null);
816
+ }, [contentHeight, width]);
817
+
818
+ const updateDividerPreview = useCallback((next: DividerPreviewState | null) => {
819
+ dividerPreviewRef.current = next;
820
+ setDividerPreview(next);
821
+ }, []);
822
+
823
+ const updateDockPreview = useCallback((next: DragPreview | null) => {
824
+ dockPreviewRef.current = next;
825
+ setDockPreview(next);
826
+ }, []);
519
827
 
520
828
  const cancelActiveDrag = useCallback(() => {
521
829
  dragRef.current = null;
522
- setDragFloatingRect(null);
830
+ updateDragFloatingRect(null);
523
831
  setDragCursor(null);
524
- setDividerPreview(null);
525
- setDockPreview(null);
526
- }, []);
527
-
528
- useKeyboard((event) => {
529
- if (event.name === "escape") {
530
- if (!dragRef.current) return;
531
- cancelActiveDrag();
532
- return;
533
- }
534
- if (event.name !== "w" || !event.ctrl) return;
535
- if (dragRef.current || overlayOpen || state.inputCaptured) return;
536
- closeFocusedPane();
537
- });
832
+ updateDividerPreview(null);
833
+ updateDockPreview(null);
834
+ }, [updateDividerPreview, updateDockPreview, updateDragFloatingRect]);
538
835
 
539
836
  const disabledPaneIds = useMemo(() => {
540
- const disabledPlugins = new Set(state.config.disabledPlugins);
837
+ const disabledPlugins = new Set(config.disabledPlugins);
541
838
  const ids = new Set<string>();
542
839
  for (const pluginId of disabledPlugins) {
543
840
  for (const paneId of pluginRegistry.getPluginPaneIds(pluginId)) {
@@ -545,7 +842,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
545
842
  }
546
843
  }
547
844
  return ids;
548
- }, [pluginRegistry, state.config.disabledPlugins]);
845
+ }, [config.disabledPlugins, pluginRegistry]);
549
846
 
550
847
  const hiddenInstanceIds = useMemo(() => (
551
848
  layout.instances
@@ -559,34 +856,53 @@ export function Shell({ pluginRegistry }: ShellProps) {
559
856
  );
560
857
 
561
858
  const persistLayout = useCallback((nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => {
562
- const layouts = state.config.layouts.map((savedLayout, index) => (
563
- index === state.config.activeLayoutIndex ? { ...savedLayout, layout: nextLayout } : savedLayout
859
+ const layouts = config.layouts.map((savedLayout, index) => (
860
+ index === config.activeLayoutIndex ? { ...savedLayout, layout: nextLayout } : savedLayout
564
861
  ));
565
862
  if (options?.pushHistory !== false) {
566
863
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
567
864
  }
568
865
  dispatch({ type: "UPDATE_LAYOUT", layout: nextLayout });
569
- saveConfig({ ...state.config, layout: nextLayout, layouts }).catch(() => {});
570
- }, [dispatch, state.config]);
866
+ scheduleConfigSave({ ...config, layout: nextLayout, layouts });
867
+ }, [config, dispatch]);
571
868
 
572
869
  const closeFocusedPane = useCallback(() => {
573
- if (!state.focusedPaneId || !isPaneInLayout(visibleLayout, state.focusedPaneId)) return;
574
- persistLayout(removePane(visibleLayout, state.focusedPaneId));
575
- }, [persistLayout, state.focusedPaneId, visibleLayout]);
870
+ if (!focusedPaneId || !isPaneInLayout(visibleLayout, focusedPaneId)) return false;
871
+ persistLayout(removePane(visibleLayout, focusedPaneId));
872
+ return true;
873
+ }, [focusedPaneId, persistLayout, visibleLayout]);
576
874
 
577
875
  const focusPane = useCallback((paneId: string) => {
578
876
  dispatch({ type: "FOCUS_PANE", paneId });
579
877
  }, [dispatch]);
580
878
 
581
879
  const openLayoutMenu = useCallback(() => {
582
- pluginRegistry.openCommandBarFn("LAY ");
880
+ pluginRegistry.openCommandBar("LAY ");
583
881
  }, [pluginRegistry]);
584
882
 
585
883
  const openPaneSettings = useCallback((paneId: string) => {
586
884
  pluginRegistry.openPaneSettingsFn(paneId);
587
885
  setMenuState(null);
886
+ setHoveredMenuItemId(null);
588
887
  }, [pluginRegistry]);
589
888
 
889
+ const copyPaneScreenshot = useCallback(async (paneId: string) => {
890
+ setMenuState(null);
891
+ setHoveredMenuItemId(null);
892
+ try {
893
+ await new Promise((resolve) => setTimeout(resolve, 0));
894
+ if (!rendererHost.copyPngImage) {
895
+ throw new Error("Image clipboard is unavailable.");
896
+ }
897
+ const screenshot = await capturePaneScreenshotPngBase64(paneId);
898
+ await rendererHost.copyPngImage(screenshot.pngBase64);
899
+ pluginRegistry.notify({ body: "Pane screenshot copied", type: "success" });
900
+ } catch (error) {
901
+ const message = error instanceof Error ? error.message : "Could not copy pane screenshot.";
902
+ pluginRegistry.notify({ body: message, type: "error" });
903
+ }
904
+ }, [pluginRegistry, rendererHost]);
905
+
590
906
  const bounds = useMemo<LayoutBounds>(() => ({ x: 0, y: 0, width, height: contentHeight }), [contentHeight, width]);
591
907
  const dockedPanes = useMemo(
592
908
  () => resolveDocked(visibleLayout, pluginRegistry.panes).filter((pane) => !disabledPaneIds.has(pane.def.id)),
@@ -596,14 +912,106 @@ export function Shell({ pluginRegistry }: ShellProps) {
596
912
  () => resolveFloating(visibleLayout, pluginRegistry.panes).filter((pane) => !disabledPaneIds.has(pane.def.id)),
597
913
  [disabledPaneIds, pluginRegistry.panes, visibleLayout],
598
914
  );
915
+ const visibleFloatingPanes = useMemo(
916
+ () => floatingPanes.map((pane) => ({
917
+ pane,
918
+ rect: constrainFloatingRectToBounds(pane.floating!, width, contentHeight),
919
+ })),
920
+ [contentHeight, floatingPanes, width],
921
+ );
599
922
  const paneMap = useMemo(() => new Map([...dockedPanes, ...floatingPanes].map((pane) => [pane.instance.instanceId, pane])), [dockedPanes, floatingPanes]);
600
- const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds), [bounds, visibleLayout]);
601
- const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds), [bounds, visibleLayout]);
923
+
924
+ const copyFocusedPaneScreenshot = useCallback(() => {
925
+ if (!focusedPaneId || !nativePaneChrome || !rendererHost.copyPngImage) return false;
926
+ if (!paneMap.has(focusedPaneId)) return false;
927
+ void copyPaneScreenshot(focusedPaneId);
928
+ return true;
929
+ }, [copyPaneScreenshot, focusedPaneId, nativePaneChrome, paneMap, rendererHost.copyPngImage]);
930
+
931
+ const openFocusedPaneSettings = useCallback(() => {
932
+ if (!focusedPaneId || !pluginRegistry.hasPaneSettings(focusedPaneId)) return false;
933
+ openPaneSettings(focusedPaneId);
934
+ return true;
935
+ }, [focusedPaneId, openPaneSettings, pluginRegistry]);
936
+
937
+ const toggleFocusedPaneFloating = useCallback(() => {
938
+ if (!focusedPaneId) return false;
939
+ const pane = paneMap.get(focusedPaneId);
940
+ if (!pane) return false;
941
+ const nextLayout = pane.floating
942
+ ? applyDrop(visibleLayout, pane.instance.instanceId, { kind: "frame", edge: "right" })
943
+ : floatPane(visibleLayout, pane.instance.instanceId, width, contentHeight, pane.def);
944
+ persistLayout(nextLayout);
945
+ focusPane(pane.instance.instanceId);
946
+ return true;
947
+ }, [contentHeight, focusPane, focusedPaneId, paneMap, persistLayout, visibleLayout, width]);
948
+
949
+ const popOutFocusedPane = useCallback(() => {
950
+ if (!focusedPaneId || desktopWindowBridge?.kind !== "main" || !desktopWindowBridge.popOutPane) return false;
951
+ if (!isPaneInLayout(visibleLayout, focusedPaneId)) return false;
952
+ void desktopWindowBridge.popOutPane(focusedPaneId);
953
+ return true;
954
+ }, [desktopWindowBridge, focusedPaneId, visibleLayout]);
955
+
956
+ const gridlockVisiblePanes = useCallback(() => {
957
+ persistLayout(gridlockAllPanes(visibleLayout, { x: 0, y: 0, width, height: contentHeight }));
958
+ return true;
959
+ }, [contentHeight, persistLayout, visibleLayout, width]);
960
+
961
+ useShortcut((event) => {
962
+ if (event.name === "escape") {
963
+ if (!dragRef.current) return;
964
+ cancelActiveDrag();
965
+ return;
966
+ }
967
+
968
+ const shortcut = resolvePaneManagementShortcut(event);
969
+ if (!shortcut || dragRef.current || overlayOpen || inputCaptured) return;
970
+
971
+ let handled = false;
972
+ switch (shortcut) {
973
+ case "close":
974
+ handled = closeFocusedPane();
975
+ break;
976
+ case "settings":
977
+ handled = openFocusedPaneSettings();
978
+ break;
979
+ case "toggle-floating":
980
+ handled = toggleFocusedPaneFloating();
981
+ break;
982
+ case "pop-out":
983
+ handled = popOutFocusedPane();
984
+ break;
985
+ case "copy-screenshot":
986
+ handled = copyFocusedPaneScreenshot();
987
+ break;
988
+ case "layout-actions":
989
+ openLayoutMenu();
990
+ handled = true;
991
+ break;
992
+ case "gridlock-all":
993
+ handled = gridlockVisiblePanes();
994
+ break;
995
+ }
996
+
997
+ if (!handled) return;
998
+ event.preventDefault();
999
+ event.stopPropagation();
1000
+ });
1001
+
1002
+ const dockGeometryOptions = useMemo(() => (nativePaneChrome ? { precise: true } : undefined), [nativePaneChrome]);
1003
+ const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
1004
+ const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
602
1005
  const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
1006
+ const externalDockPreview = useMemo(
1007
+ () => resolveExternalDockPreview(desktopDockPreview, bounds),
1008
+ [bounds, desktopDockPreview],
1009
+ );
603
1010
  const activePaneDrag = dragRef.current?.type === "pane-drag" ? dragRef.current : null;
604
1011
  const activeHoverOverlay = activePaneDrag && dragCursor
605
1012
  ? resolveHoverOverlay(dragCursor.x, dragCursor.y, dockLeafLayouts, activePaneDrag.paneId)
606
1013
  : null;
1014
+ const effectiveDockPreview = dockPreview ?? externalDockPreview;
607
1015
  const nativeTransientOccluders = useMemo<NativeTransientOccluder[]>(() => {
608
1016
  const occluders: NativeTransientOccluder[] = [];
609
1017
 
@@ -620,7 +1028,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
620
1028
  if (activePaneDrag
621
1029
  && activePaneDrag.mode === "docked"
622
1030
  && dragFloatingRect?.paneId === activePaneDrag.paneId
623
- && !dockPreview) {
1031
+ && !effectiveDockPreview) {
624
1032
  occluders.push({
625
1033
  id: `drag-preview:${activePaneDrag.paneId}`,
626
1034
  rect: dragFloatingRect.rect,
@@ -628,16 +1036,16 @@ export function Shell({ pluginRegistry }: ShellProps) {
628
1036
  });
629
1037
  }
630
1038
 
631
- if (dockPreview) {
1039
+ if (effectiveDockPreview) {
632
1040
  occluders.push({
633
- id: `dock-preview:${dockPreview.kind}`,
634
- rect: dockPreview.rect,
1041
+ id: `dock-preview:${effectiveDockPreview.kind}`,
1042
+ rect: effectiveDockPreview.rect,
635
1043
  zIndex: 96,
636
1044
  });
637
1045
  }
638
1046
 
639
1047
  return occluders;
640
- }, [activeHoverOverlay, activePaneDrag, dockPreview, dragFloatingRect]);
1048
+ }, [activeHoverOverlay, activePaneDrag, dragFloatingRect, effectiveDockPreview]);
641
1049
  const nativeDockDividers = useMemo(
642
1050
  () => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
643
1051
  [dividerPreview, dockDividerLayouts],
@@ -645,97 +1053,252 @@ export function Shell({ pluginRegistry }: ShellProps) {
645
1053
  const nativeWindowState = useMemo(
646
1054
  () => buildNativeWindowState(
647
1055
  dockedPanes.map((pane) => pane.instance.instanceId),
648
- floatingPanes.map((pane) => ({
1056
+ visibleFloatingPanes.map(({ pane, rect }) => ({
649
1057
  paneId: pane.instance.instanceId,
650
- rect: pane.floating!,
1058
+ rect,
651
1059
  zIndex: pane.floating?.zIndex ?? 50,
652
1060
  })),
653
1061
  dragFloatingRect,
654
1062
  { open: overlayOpen, width, contentHeight },
655
1063
  nativeTransientOccluders,
656
1064
  nativeDockDividers,
1065
+ appHeaderHeight,
657
1066
  ),
658
- [contentHeight, dockedPanes, dragFloatingRect, floatingPanes, nativeDockDividers, nativeTransientOccluders, overlayOpen, width],
1067
+ [appHeaderHeight, contentHeight, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, overlayOpen, visibleFloatingPanes, width],
659
1068
  );
660
1069
 
661
1070
  useEffect(() => {
662
1071
  nativeSurfaceManager.setWindowState(nativeWindowState);
663
1072
  }, [nativeSurfaceManager, nativeWindowState]);
664
1073
 
665
- const getPaneTitle = useCallback((pane: ResolvedPane): string => {
666
- if (pane.instance.paneId === "ticker-detail") {
667
- const ticker = resolveTickerForPane(state, pane.instance.instanceId);
668
- if (ticker) return ticker;
669
- const collectionId = resolveCollectionForPane(state, pane.instance.instanceId);
670
- return state.config.portfolios.find((portfolio) => portfolio.id === collectionId)?.name
671
- ?? state.config.watchlists.find((watchlist) => watchlist.id === collectionId)?.name
672
- ?? pane.instance.title
673
- ?? pane.def.name;
674
- }
675
- if (pane.instance.title) return pane.instance.title;
676
- if (pane.instance.paneId === "portfolio-list") {
677
- const collectionId = resolveCollectionForPane(state, pane.instance.instanceId);
678
- return state.config.portfolios.find((portfolio) => portfolio.id === collectionId)?.name
679
- ?? state.config.watchlists.find((watchlist) => watchlist.id === collectionId)?.name
680
- ?? pane.def.name;
681
- }
682
- const ticker = resolveTickerForPane(state, pane.instance.instanceId);
683
- return ticker ? `${pane.def.name}: ${ticker}` : pane.def.name;
684
- }, [state]);
1074
+ const titleState = useMemo(
1075
+ () => ({ config, paneState }) as Parameters<typeof resolveTickerForPane>[0],
1076
+ [config, paneState],
1077
+ );
1078
+ const getPaneTitle = useCallback(
1079
+ (pane: ResolvedPane): string => getPaneDisplayTitle(titleState, pane.instance, pane.def),
1080
+ [titleState],
1081
+ );
685
1082
 
686
- const openPaneMenu = useCallback((paneId: string, rect: LayoutBounds) => {
1083
+ const openPaneMenu = useCallback((paneId: string, rect: LayoutBounds, event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
687
1084
  const pane = paneMap.get(paneId);
688
1085
  if (!pane) return;
689
1086
  focusPane(paneId);
690
- const menuX = Math.max(0, Math.min(width - MENU_WIDTH, rect.x + Math.max(0, rect.width - MENU_WIDTH)));
691
- const menuY = Math.max(0, Math.min(contentHeight - 1, rect.y + 1));
692
- setMenuState({
1087
+ const context = {
1088
+ kind: "pane" as const,
693
1089
  paneId,
694
- x: menuX,
695
- y: menuY,
696
- items: menuForPane(
697
- pane,
698
- visibleLayout,
699
- width,
700
- contentHeight,
701
- pluginRegistry,
702
- persistLayout,
703
- focusPane,
704
- openPaneSettings,
705
- ),
1090
+ paneType: pane.instance.paneId,
1091
+ title: getPaneTitle(pane),
1092
+ floating: !!pane.floating,
1093
+ };
1094
+ const items = menuForPane(
1095
+ pane,
1096
+ visibleLayout,
1097
+ width,
1098
+ contentHeight,
1099
+ pluginRegistry,
1100
+ persistLayout,
1101
+ focusPane,
1102
+ openPaneSettings,
1103
+ desktopWindowBridge,
1104
+ nativePaneChrome && rendererHost.copyPngImage ? copyPaneScreenshot : undefined,
1105
+ );
1106
+ void showContextMenu(context, items, event).then((shown) => {
1107
+ if (shown) return;
1108
+ const pluginItems = pluginRegistry.getContextMenuItems?.(context) ?? [];
1109
+ const fallbackSourceItems = compactContextMenuItems([
1110
+ ...items,
1111
+ ...(items.length > 0 && pluginItems.length > 0 ? [contextMenuDivider(`${context.kind}:plugin-divider`)] : []),
1112
+ ...pluginItems,
1113
+ ]);
1114
+ const fallbackItems = menuItemsForFallback(fallbackSourceItems, shortcutDisplayMode);
1115
+ if (fallbackItems.length === 0) return;
1116
+ const menuWidth = actionMenuWidth(fallbackItems, width);
1117
+ const menuX = Math.max(0, Math.min(width - menuWidth, rect.x + Math.max(0, rect.width - menuWidth)));
1118
+ const menuY = Math.max(0, Math.min(contentHeight - 1, rect.y + 1));
1119
+ setHoveredMenuItemId(fallbackItems[0]?.id ?? null);
1120
+ setMenuState({
1121
+ paneId,
1122
+ x: menuX,
1123
+ y: menuY,
1124
+ width: menuWidth,
1125
+ items: fallbackItems,
1126
+ });
706
1127
  });
707
- }, [contentHeight, focusPane, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
1128
+ }, [contentHeight, copyPaneScreenshot, desktopWindowBridge, focusPane, getPaneTitle, nativePaneChrome, openPaneSettings, paneMap, persistLayout, pluginRegistry, rendererHost.copyPngImage, shortcutDisplayMode, showContextMenu, visibleLayout, width]);
708
1129
 
709
1130
  const handleFloatingClose = useCallback((paneId: string) => {
710
1131
  persistLayout(removePane(visibleLayout, paneId));
711
1132
  }, [persistLayout, visibleLayout]);
712
1133
 
713
- const handleMouse = useCallback((event: {
714
- type: string;
715
- x: number;
716
- y: number;
717
- stopPropagation: () => void;
718
- preventDefault: () => void;
719
- }) => {
720
- const shellY = event.y - HEADER_HEIGHT;
1134
+ const handleActiveDrag = useCallback((event: ShellMouseEvent) => {
1135
+ const shellY = event.y - appHeaderHeight;
1136
+ const preciseX = event.preciseX ?? event.x;
1137
+ const preciseShellY = (event.preciseY ?? event.y) - appHeaderHeight;
1138
+ const hitX = precisePointer ? preciseX : event.x;
1139
+ const hitShellY = precisePointer ? preciseShellY : shellY;
1140
+ const dragThreshold = precisePointer ? PRECISE_PANE_DRAG_THRESHOLD : PANE_DRAG_THRESHOLD;
1141
+ const drag = dragRef.current;
1142
+ if (!drag) return;
1143
+
1144
+ if (event.type === "drag") {
1145
+ if (drag.type === "divider") {
1146
+ const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
1147
+ const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
1148
+ const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
1149
+ const offset = drag.axis === "horizontal"
1150
+ ? drag.bounds.width * nextRatio
1151
+ : drag.bounds.height * nextRatio;
1152
+ const nextRect = drag.axis === "horizontal"
1153
+ ? {
1154
+ x: nativePaneChrome ? drag.bounds.x + offset - 0.5 : drag.bounds.x + Math.round(offset) - 1,
1155
+ y: drag.bounds.y,
1156
+ width: 1,
1157
+ height: drag.bounds.height,
1158
+ }
1159
+ : {
1160
+ x: drag.bounds.x,
1161
+ y: nativePaneChrome ? drag.bounds.y + offset - 0.5 : drag.bounds.y + Math.round(offset) - 1,
1162
+ width: drag.bounds.width,
1163
+ height: 1,
1164
+ };
1165
+ updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
1166
+ } else if (drag.type === "pane-drag") {
1167
+ if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
1168
+ updateDockPreview(null);
1169
+ setDragCursor(null);
1170
+ if (drag.mode === "floating") {
1171
+ updateDragFloatingRect({ paneId: drag.paneId, rect: drag.origRect });
1172
+ }
1173
+ event.stopPropagation();
1174
+ event.preventDefault();
1175
+ return;
1176
+ }
1177
+
1178
+ const pane = paneMap.get(drag.paneId);
1179
+ const baseRect = drag.mode === "docked"
1180
+ ? getRememberedFloatingRect(visibleLayout, drag.paneId, width, contentHeight, pane?.def)
1181
+ : drag.origRect;
1182
+ const nextRect = resolvePaneDragFloatingRect(drag, baseRect, preciseX, preciseShellY, width, contentHeight);
1183
+ updateDragFloatingRect({ paneId: drag.paneId, rect: nextRect });
1184
+ setDragCursor({ x: hitX, y: hitShellY });
1185
+
1186
+ const hoveredOverlay = resolveHoverOverlay(hitX, hitShellY, dockLeafLayouts, drag.paneId);
1187
+ if (hoveredOverlay) {
1188
+ const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, hitX, hitShellY));
1189
+ if (hoveredCell) {
1190
+ const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
1191
+ const simulation = simulateDrop(visibleLayout, drag.paneId, target, bounds);
1192
+ if (simulation.previewRect) {
1193
+ updateDockPreview({ kind: "dock", target, rect: simulation.previewRect });
1194
+ } else {
1195
+ updateDockPreview(null);
1196
+ }
1197
+ } else {
1198
+ updateDockPreview(null);
1199
+ }
1200
+ } else {
1201
+ const snapGuide = resolveSnapGuide(hitX, hitShellY, snapGuides);
1202
+ updateDockPreview(snapGuide ? { kind: "snap", position: snapGuide.position, rect: snapGuide.previewRect } : null);
1203
+ }
1204
+ } else if (drag.type === "float-resize") {
1205
+ updateDragFloatingRect({
1206
+ paneId: drag.paneId,
1207
+ rect: resolveFloatResizeRect(drag, preciseX, preciseShellY, width, contentHeight),
1208
+ });
1209
+ }
1210
+ event.stopPropagation();
1211
+ event.preventDefault();
1212
+ return;
1213
+ }
1214
+
1215
+ if (event.type === "up" || event.type === "drag-end") {
1216
+ if (drag.type === "divider") {
1217
+ const preview = dividerPreviewRef.current;
1218
+ if (preview) {
1219
+ persistLayout(resizeSplitAtPath(visibleLayout, drag.path, preview.ratio));
1220
+ }
1221
+ updateDividerPreview(null);
1222
+ } else if (drag.type === "pane-drag") {
1223
+ const movedEnough = isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold);
1224
+ if (!movedEnough) {
1225
+ updateDockPreview(null);
1226
+ setDragCursor(null);
1227
+ updateDragFloatingRect(null);
1228
+ } else {
1229
+ const pane = paneMap.get(drag.paneId);
1230
+ const baseRect = drag.mode === "docked"
1231
+ ? getRememberedFloatingRect(visibleLayout, drag.paneId, width, contentHeight, pane?.def)
1232
+ : drag.origRect;
1233
+ const releaseRect = resolvePaneDragFloatingRect(drag, baseRect, preciseX, preciseShellY, width, contentHeight);
1234
+ const releaseResult = finalizePaneDragRelease(visibleLayout, drag.paneId, releaseRect, dockPreviewRef.current);
1235
+ persistLayout(releaseResult.nextLayout);
1236
+ focusPane(drag.paneId);
1237
+ if (releaseResult.shouldShowGridlockTip) {
1238
+ dispatch({ type: "SHOW_GRIDLOCK_TIP" });
1239
+ }
1240
+ updateDockPreview(null);
1241
+ setDragCursor(null);
1242
+ updateDragFloatingRect(null);
1243
+ }
1244
+ } else if (drag.type === "float-resize") {
1245
+ const releaseRect = resolveFloatResizeRect(drag, preciseX, preciseShellY, width, contentHeight);
1246
+ persistLayout(floatAtRect(visibleLayout, drag.paneId, releaseRect));
1247
+ updateDragFloatingRect(null);
1248
+ setDragCursor(null);
1249
+ }
1250
+ dragRef.current = null;
1251
+ event.stopPropagation();
1252
+ event.preventDefault();
1253
+ }
1254
+ }, [
1255
+ bounds,
1256
+ appHeaderHeight,
1257
+ contentHeight,
1258
+ dockLeafLayouts,
1259
+ dispatch,
1260
+ focusPane,
1261
+ nativePaneChrome,
1262
+ paneMap,
1263
+ persistLayout,
1264
+ precisePointer,
1265
+ snapGuides,
1266
+ updateDividerPreview,
1267
+ updateDockPreview,
1268
+ updateDragFloatingRect,
1269
+ visibleLayout,
1270
+ width,
1271
+ ]);
1272
+
1273
+ const handleMouse = useCallback((event: ShellMouseEvent) => {
1274
+ const shellY = event.y - appHeaderHeight;
1275
+ const preciseX = event.preciseX ?? event.x;
1276
+ const preciseShellY = (event.preciseY ?? event.y) - appHeaderHeight;
721
1277
  if (shellY < 0) return;
722
1278
 
723
1279
  if (event.type === "down") {
724
1280
  if (menuState) {
725
1281
  setMenuState(null);
1282
+ setHoveredMenuItemId(null);
726
1283
  }
727
1284
 
728
- for (const pane of [...floatingPanes].sort((a, b) => (b.floating?.zIndex ?? 50) - (a.floating?.zIndex ?? 50))) {
729
- const rect = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1285
+ for (const { pane, rect: visibleRect } of [...visibleFloatingPanes].sort((a, b) => (b.pane.floating?.zIndex ?? 50) - (a.pane.floating?.zIndex ?? 50))) {
1286
+ const rect = dragFloatingRect?.paneId === pane.instance.instanceId
1287
+ ? constrainFloatingRectToBounds(dragFloatingRect.rect, width, contentHeight)
1288
+ : visibleRect;
730
1289
  if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
731
1290
  const relativeX = event.x - rect.x;
732
1291
  const relativeY = shellY - rect.y;
733
- const isFocused = state.focusedPaneId === pane.instance.instanceId;
1292
+ const isFocused = focusedPaneId === pane.instance.instanceId;
734
1293
  const headerAreas = resolveHeaderHitAreas(rect.width, {
735
1294
  floating: true,
736
1295
  focused: isFocused,
737
1296
  });
738
1297
  focusPane(pane.instance.instanceId);
1298
+ if (event.button === 2 && relativeY === 0) {
1299
+ openPaneMenu(pane.instance.instanceId, rect, event);
1300
+ return;
1301
+ }
739
1302
  if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
740
1303
  handleFloatingClose(pane.instance.instanceId);
741
1304
  event.stopPropagation();
@@ -747,7 +1310,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
747
1310
  && headerAreas.closeStart != null
748
1311
  && relativeX >= headerAreas.actionStart
749
1312
  && relativeX < headerAreas.closeStart) {
750
- openPaneMenu(pane.instance.instanceId, rect);
1313
+ openPaneMenu(pane.instance.instanceId, rect, event);
751
1314
  event.stopPropagation();
752
1315
  event.preventDefault();
753
1316
  return;
@@ -756,11 +1319,11 @@ export function Shell({ pluginRegistry }: ShellProps) {
756
1319
  dragRef.current = {
757
1320
  type: "float-resize",
758
1321
  paneId: pane.instance.instanceId,
759
- startX: event.x,
760
- startY: shellY,
1322
+ startX: preciseX,
1323
+ startY: preciseShellY,
761
1324
  origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
762
1325
  };
763
- setDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
1326
+ updateDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
764
1327
  event.stopPropagation();
765
1328
  event.preventDefault();
766
1329
  return;
@@ -770,11 +1333,11 @@ export function Shell({ pluginRegistry }: ShellProps) {
770
1333
  type: "pane-drag",
771
1334
  paneId: pane.instance.instanceId,
772
1335
  mode: "floating",
773
- startX: event.x,
774
- startY: shellY,
1336
+ startX: preciseX,
1337
+ startY: preciseShellY,
775
1338
  origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
776
1339
  };
777
- setDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
1340
+ updateDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
778
1341
  event.stopPropagation();
779
1342
  event.preventDefault();
780
1343
  return;
@@ -790,12 +1353,12 @@ export function Shell({ pluginRegistry }: ShellProps) {
790
1353
  type: "divider",
791
1354
  path: divider.path,
792
1355
  axis: divider.axis,
793
- startX: event.x,
794
- startY: shellY,
1356
+ startX: preciseX,
1357
+ startY: preciseShellY,
795
1358
  startRatio: divider.ratio,
796
1359
  bounds: divider.bounds,
797
1360
  };
798
- setDividerPreview({
1361
+ updateDividerPreview({
799
1362
  pathKey: divider.path.join("."),
800
1363
  rect: divider.rect,
801
1364
  ratio: divider.ratio,
@@ -811,16 +1374,20 @@ export function Shell({ pluginRegistry }: ShellProps) {
811
1374
  if (!pane) continue;
812
1375
  const relativeX = event.x - leaf.rect.x;
813
1376
  const relativeY = shellY - leaf.rect.y;
814
- const isFocused = state.focusedPaneId === leaf.instanceId;
1377
+ const isFocused = focusedPaneId === leaf.instanceId;
815
1378
  const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
816
1379
  floating: false,
817
1380
  focused: isFocused,
818
1381
  });
819
1382
  focusPane(leaf.instanceId);
1383
+ if (event.button === 2 && relativeY === 0) {
1384
+ openPaneMenu(leaf.instanceId, leaf.rect, event);
1385
+ return;
1386
+ }
820
1387
  if (relativeY === 0
821
1388
  && headerAreas.actionStart != null
822
1389
  && relativeX >= headerAreas.actionStart) {
823
- openPaneMenu(leaf.instanceId, leaf.rect);
1390
+ openPaneMenu(leaf.instanceId, leaf.rect, event);
824
1391
  event.stopPropagation();
825
1392
  event.preventDefault();
826
1393
  return;
@@ -830,8 +1397,8 @@ export function Shell({ pluginRegistry }: ShellProps) {
830
1397
  type: "pane-drag",
831
1398
  paneId: leaf.instanceId,
832
1399
  mode: "docked",
833
- startX: event.x,
834
- startY: shellY,
1400
+ startX: preciseX,
1401
+ startY: preciseShellY,
835
1402
  origRect: { x: leaf.rect.x, y: leaf.rect.y, width: leaf.rect.width, height: leaf.rect.height },
836
1403
  };
837
1404
  event.stopPropagation();
@@ -846,150 +1413,179 @@ export function Shell({ pluginRegistry }: ShellProps) {
846
1413
  return;
847
1414
  }
848
1415
 
849
- const drag = dragRef.current;
850
- if (!drag) return;
851
-
852
- if (event.type === "drag") {
853
- if (drag.type === "divider") {
854
- const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
855
- const delta = drag.axis === "horizontal" ? event.x - drag.startX : shellY - drag.startY;
856
- const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
857
- const nextRect = drag.axis === "horizontal"
858
- ? { x: drag.bounds.x + Math.round(drag.bounds.width * nextRatio) - 1, y: drag.bounds.y, width: 1, height: drag.bounds.height }
859
- : { x: drag.bounds.x, y: drag.bounds.y + Math.round(drag.bounds.height * nextRatio) - 1, width: drag.bounds.width, height: 1 };
860
- setDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
861
- } else if (drag.type === "pane-drag") {
862
- if (!isMeaningfulPaneDrag(drag.startX, drag.startY, event.x, shellY)) {
863
- setDockPreview(null);
864
- setDragCursor(null);
865
- if (drag.mode === "floating") {
866
- setDragFloatingRect({ paneId: drag.paneId, rect: drag.origRect });
867
- }
868
- event.stopPropagation();
869
- event.preventDefault();
870
- return;
871
- }
872
-
873
- const pane = paneMap.get(drag.paneId);
874
- const nextRect = drag.mode === "docked"
875
- ? positionFloatingRectUnderPointer(
876
- getRememberedFloatingRect(visibleLayout, drag.paneId, width, contentHeight, pane?.def),
877
- drag,
878
- event.x,
879
- shellY,
880
- width,
881
- contentHeight,
882
- )
883
- : {
884
- x: Math.max(0, Math.min(width - drag.origRect.width, drag.origRect.x + (event.x - drag.startX))),
885
- y: Math.max(0, Math.min(contentHeight - drag.origRect.height, drag.origRect.y + (shellY - drag.startY))),
886
- width: drag.origRect.width,
887
- height: drag.origRect.height,
888
- };
889
- setDragFloatingRect({ paneId: drag.paneId, rect: nextRect });
890
- setDragCursor({ x: event.x, y: shellY });
891
-
892
- const hoveredOverlay = resolveHoverOverlay(event.x, shellY, dockLeafLayouts, drag.paneId);
893
- if (hoveredOverlay) {
894
- const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, event.x, shellY));
895
- if (hoveredCell) {
896
- const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
897
- const simulation = simulateDrop(visibleLayout, drag.paneId, target, bounds);
898
- if (simulation.previewRect) {
899
- setDockPreview({ kind: "dock", target, rect: simulation.previewRect });
900
- } else {
901
- setDockPreview(null);
902
- }
903
- } else {
904
- setDockPreview(null);
905
- }
906
- } else {
907
- const snapGuide = resolveSnapGuide(event.x, shellY, snapGuides);
908
- setDockPreview(snapGuide ? { kind: "snap", position: snapGuide.position, rect: snapGuide.previewRect } : null);
909
- }
910
- } else if (drag.type === "float-resize") {
911
- setDragFloatingRect({
912
- paneId: drag.paneId,
913
- rect: {
914
- x: drag.origRect.x,
915
- y: drag.origRect.y,
916
- width: Math.max(MIN_FLOAT_WIDTH, Math.min(width - drag.origRect.x, drag.origRect.width + (event.x - drag.startX))),
917
- height: Math.max(MIN_FLOAT_HEIGHT, Math.min(contentHeight - drag.origRect.y, drag.origRect.height + (shellY - drag.startY))),
918
- },
919
- });
920
- }
921
- event.stopPropagation();
922
- event.preventDefault();
923
- return;
924
- }
925
-
926
- if (event.type === "up" || event.type === "drag-end") {
927
- if (drag.type === "divider") {
928
- if (dividerPreview) {
929
- persistLayout(resizeSplitAtPath(visibleLayout, drag.path, dividerPreview.ratio));
930
- }
931
- setDividerPreview(null);
932
- } else if (drag.type === "pane-drag") {
933
- const movedEnough = isMeaningfulPaneDrag(drag.startX, drag.startY, event.x, shellY);
934
- const previewRect = dragFloatingRect?.paneId === drag.paneId ? dragFloatingRect.rect : drag.origRect;
935
- if (!movedEnough) {
936
- setDockPreview(null);
937
- setDragCursor(null);
938
- setDragFloatingRect(null);
939
- } else {
940
- const releaseResult = finalizePaneDragRelease(visibleLayout, drag.paneId, previewRect, dockPreview);
941
- persistLayout(releaseResult.nextLayout);
942
- focusPane(drag.paneId);
943
- if (releaseResult.shouldShowGridlockTip) {
944
- dispatch({ type: "SHOW_GRIDLOCK_TIP" });
945
- }
946
- setDockPreview(null);
947
- setDragCursor(null);
948
- setDragFloatingRect(null);
949
- }
950
- } else if (drag.type === "float-resize") {
951
- if (dragFloatingRect?.paneId === drag.paneId) {
952
- persistLayout(floatAtRect(visibleLayout, drag.paneId, dragFloatingRect.rect));
953
- }
954
- setDragFloatingRect(null);
955
- setDragCursor(null);
956
- }
957
- dragRef.current = null;
958
- event.stopPropagation();
959
- event.preventDefault();
960
- }
1416
+ handleActiveDrag(event);
961
1417
  }, [
962
1418
  bounds,
1419
+ appHeaderHeight,
963
1420
  closeFocusedPane,
964
1421
  contentHeight,
965
1422
  dividerPreview,
966
1423
  dockDividerLayouts,
967
1424
  dockLeafLayouts,
968
- dockPreview,
1425
+ effectiveDockPreview,
969
1426
  dragFloatingRect,
970
- floatingPanes,
1427
+ focusedPaneId,
971
1428
  focusPane,
1429
+ handleActiveDrag,
972
1430
  handleFloatingClose,
973
1431
  menuState,
1432
+ nativePaneChrome,
974
1433
  openPaneMenu,
975
1434
  paneMap,
976
1435
  persistLayout,
1436
+ precisePointer,
977
1437
  snapGuides,
1438
+ updateDividerPreview,
1439
+ updateDockPreview,
1440
+ updateDragFloatingRect,
1441
+ visibleFloatingPanes,
978
1442
  visibleLayout,
979
1443
  width,
980
1444
  ]);
981
1445
 
1446
+ const getShellPointer = useCallback((event: ShellMouseEvent) => ({
1447
+ x: event.preciseX ?? event.x,
1448
+ y: (event.preciseY ?? event.y) - appHeaderHeight,
1449
+ }), [appHeaderHeight]);
1450
+
1451
+ const focusNativePane = useCallback((paneId: string) => {
1452
+ if (menuState) {
1453
+ setMenuState(null);
1454
+ setHoveredMenuItemId(null);
1455
+ }
1456
+ focusPane(paneId);
1457
+ }, [focusPane, menuState]);
1458
+
1459
+ const startNativeFloatingDrag = useCallback((paneId: string, rect: FloatingRect, event: ShellMouseEvent) => {
1460
+ if (!nativePaneChrome) return;
1461
+ if (event.button === 2) {
1462
+ return;
1463
+ }
1464
+ const pointer = getShellPointer(event);
1465
+ focusNativePane(paneId);
1466
+ dragRef.current = {
1467
+ type: "pane-drag",
1468
+ paneId,
1469
+ mode: "floating",
1470
+ startX: pointer.x,
1471
+ startY: pointer.y,
1472
+ origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1473
+ };
1474
+ updateDragFloatingRect({ paneId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
1475
+ event.preventDefault();
1476
+ }, [focusNativePane, getShellPointer, nativePaneChrome, updateDragFloatingRect]);
1477
+
1478
+ const startNativeDockedDrag = useCallback((paneId: string, rect: LayoutBounds, event: ShellMouseEvent) => {
1479
+ if (!nativePaneChrome) return;
1480
+ if (event.button === 2) {
1481
+ return;
1482
+ }
1483
+ const pointer = getShellPointer(event);
1484
+ focusNativePane(paneId);
1485
+ dragRef.current = {
1486
+ type: "pane-drag",
1487
+ paneId,
1488
+ mode: "docked",
1489
+ startX: pointer.x,
1490
+ startY: pointer.y,
1491
+ origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1492
+ };
1493
+ event.preventDefault();
1494
+ }, [focusNativePane, getShellPointer, nativePaneChrome]);
1495
+
1496
+ const startNativeFloatResize = useCallback((paneId: string, rect: FloatingRect, event: ShellMouseEvent) => {
1497
+ if (!nativePaneChrome) return;
1498
+ const pointer = getShellPointer(event);
1499
+ focusNativePane(paneId);
1500
+ dragRef.current = {
1501
+ type: "float-resize",
1502
+ paneId,
1503
+ startX: pointer.x,
1504
+ startY: pointer.y,
1505
+ origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1506
+ };
1507
+ updateDragFloatingRect({ paneId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
1508
+ event.preventDefault();
1509
+ }, [focusNativePane, getShellPointer, nativePaneChrome, updateDragFloatingRect]);
1510
+
1511
+ const startNativeDividerDrag = useCallback((divider: DockDividerLayout, event: ShellMouseEvent) => {
1512
+ if (!nativePaneChrome) return;
1513
+ const pointer = getShellPointer(event);
1514
+ if (menuState) {
1515
+ setMenuState(null);
1516
+ setHoveredMenuItemId(null);
1517
+ }
1518
+ dragRef.current = {
1519
+ type: "divider",
1520
+ path: divider.path,
1521
+ axis: divider.axis,
1522
+ startX: pointer.x,
1523
+ startY: pointer.y,
1524
+ startRatio: divider.ratio,
1525
+ bounds: divider.bounds,
1526
+ };
1527
+ updateDividerPreview({
1528
+ pathKey: divider.path.join("."),
1529
+ rect: divider.rect,
1530
+ ratio: divider.ratio,
1531
+ });
1532
+ event.preventDefault();
1533
+ }, [getShellPointer, menuState, nativePaneChrome, updateDividerPreview]);
1534
+
1535
+ const handleNativeDrag = useCallback((event: ShellMouseEvent) => {
1536
+ handleActiveDrag(event);
1537
+ }, [handleActiveDrag]);
1538
+
1539
+ const handlePaneAction = useCallback((paneId: string, rect: LayoutBounds, event: ShellMouseEvent) => {
1540
+ if (event.button === 2) return;
1541
+ event.stopPropagation();
1542
+ event.preventDefault();
1543
+ openPaneMenu(paneId, rect, event);
1544
+ }, [openPaneMenu]);
1545
+
1546
+ const handleNativePaneContextMenu = useCallback((paneId: string, rect: LayoutBounds, event: ShellMouseEvent) => {
1547
+ openPaneMenu(paneId, rect, event);
1548
+ }, [openPaneMenu]);
1549
+
1550
+ const handleFloatingCloseMouseDown = useCallback((paneId: string, event: ShellMouseEvent) => {
1551
+ event.stopPropagation();
1552
+ event.preventDefault();
1553
+ handleFloatingClose(paneId);
1554
+ }, [handleFloatingClose]);
1555
+
982
1556
  return (
983
- <box flexDirection="row" flexGrow={1} height={contentHeight} onMouse={handleMouse}>
1557
+ <Box
1558
+ flexDirection="row"
1559
+ flexGrow={1}
1560
+ height={nativePaneChrome ? undefined : contentHeight}
1561
+ position={nativePaneChrome ? "relative" : undefined}
1562
+ overflow="hidden"
1563
+ {...(!nativePaneChrome ? { onMouse: handleMouse } : {})}
1564
+ >
1565
+ <Box
1566
+ position="absolute"
1567
+ left={0}
1568
+ top={0}
1569
+ width={width}
1570
+ height={contentHeight}
1571
+ alignItems="center"
1572
+ justifyContent="center"
1573
+ >
1574
+ <Box flexDirection="column" alignItems="center">
1575
+ <AsciiText text="Gloomberb" font="wordmark" color={colors.textMuted} />
1576
+ <Box height={1} />
1577
+ <Text fg={colors.textDim}>Ctrl+P to get started.</Text>
1578
+ </Box>
1579
+ </Box>
1580
+
984
1581
  {dockLeafLayouts.map((leaf) => {
985
1582
  const pane = paneMap.get(leaf.instanceId);
986
1583
  if (!pane) return null;
987
- const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
1584
+ const focused = focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
988
1585
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
989
- const bodyHeight = getPaneBodyHeight(leaf.rect.height);
990
- const bodyWidth = getPaneBodyWidth(leaf.rect.width, focused);
1586
+ const bodyWidth = nativePaneChrome ? Math.max(1, Math.floor(leaf.rect.width)) : getPaneBodyWidth(leaf.rect.width);
991
1587
  return (
992
- <box
1588
+ <Box
993
1589
  key={`dock:${leaf.instanceId}`}
994
1590
  position="absolute"
995
1591
  left={leaf.rect.x}
@@ -997,72 +1593,100 @@ export function Shell({ pluginRegistry }: ShellProps) {
997
1593
  width={leaf.rect.width}
998
1594
  height={leaf.rect.height}
999
1595
  >
1000
- <PaneWrapper
1001
- title={getPaneTitle(pane)}
1002
- focused={focused}
1003
- width={leaf.rect.width}
1004
- height={leaf.rect.height}
1005
- showActions={showActions}
1006
- onMouseMove={() => setHoveredPaneId(leaf.instanceId)}
1007
- >
1008
- <PaneInstanceProvider paneId={leaf.instanceId}>
1009
- <pane.def.component
1010
- paneId={pane.instance.instanceId}
1011
- paneType={pane.instance.paneId}
1012
- focused={focused}
1013
- width={bodyWidth}
1014
- height={bodyHeight}
1015
- />
1016
- </PaneInstanceProvider>
1017
- </PaneWrapper>
1018
- </box>
1596
+ <PaneFooterProvider>
1597
+ {(footer) => {
1598
+ const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
1599
+ const bodyHeight = getPaneBodyHeight(leaf.rect.height, reserveFooter);
1600
+ return (
1601
+ <PaneWrapper
1602
+ paneId={leaf.instanceId}
1603
+ title={getPaneTitle(pane)}
1604
+ focused={focused}
1605
+ width={leaf.rect.width}
1606
+ height={leaf.rect.height}
1607
+ showActions={showActions}
1608
+ footer={footer}
1609
+ onMouseDown={nativePaneChrome ? () => focusNativePane(leaf.instanceId) : undefined}
1610
+ onMouseMove={() => setHoveredPaneIfChanged(leaf.instanceId)}
1611
+ onHeaderMouseDown={nativePaneChrome ? (event) => startNativeDockedDrag(leaf.instanceId, leaf.rect, event) : undefined}
1612
+ onHeaderMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
1613
+ onHeaderMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
1614
+ onHeaderContextMenu={nativePaneChrome && nativeContextMenu === true ? (event) => handleNativePaneContextMenu(leaf.instanceId, leaf.rect, event) : undefined}
1615
+ onActionMouseDown={(event) => handlePaneAction(leaf.instanceId, leaf.rect, event)}
1616
+ >
1617
+ <PaneContent
1618
+ component={pane.def.component}
1619
+ paneId={pane.instance.instanceId}
1620
+ paneType={pane.instance.paneId}
1621
+ focused={focused}
1622
+ width={bodyWidth}
1623
+ height={bodyHeight}
1624
+ />
1625
+ </PaneWrapper>
1626
+ );
1627
+ }}
1628
+ </PaneFooterProvider>
1629
+ </Box>
1019
1630
  );
1020
1631
  })}
1021
1632
 
1022
- {floatingPanes.map((pane) => {
1023
- const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1024
- const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1633
+ {visibleFloatingPanes.map(({ pane, rect }) => {
1634
+ const preview = dragFloatingRect?.paneId === pane.instance.instanceId
1635
+ ? constrainFloatingRectToBounds(dragFloatingRect.rect, width, contentHeight)
1636
+ : rect;
1637
+ const focused = focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1025
1638
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1026
- const bodyHeight = getPaneBodyHeight(preview.height);
1027
- const bodyWidth = getPaneBodyWidth(preview.width, focused);
1639
+ const bodyWidth = nativePaneChrome ? Math.max(1, Math.floor(preview.width)) : getPaneBodyWidth(preview.width);
1028
1640
  return (
1029
- <FloatingPaneWrapper
1030
- key={`float:${pane.instance.instanceId}`}
1031
- title={getPaneTitle(pane)}
1032
- x={preview.x}
1033
- y={preview.y}
1034
- width={preview.width}
1035
- height={preview.height}
1036
- zIndex={pane.floating?.zIndex ?? 50}
1037
- focused={focused}
1038
- showActions={showActions}
1039
- onMouseMove={() => setHoveredPaneId(pane.instance.instanceId)}
1040
- >
1041
- <PaneInstanceProvider paneId={pane.instance.instanceId}>
1042
- <pane.def.component
1043
- paneId={pane.instance.instanceId}
1044
- paneType={pane.instance.paneId}
1045
- focused={focused}
1046
- width={bodyWidth}
1047
- height={bodyHeight}
1048
- close={() => handleFloatingClose(pane.instance.instanceId)}
1049
- />
1050
- </PaneInstanceProvider>
1051
- </FloatingPaneWrapper>
1641
+ <PaneFooterProvider key={`float:${pane.instance.instanceId}`}>
1642
+ {(footer) => {
1643
+ const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
1644
+ const bodyHeight = getPaneBodyHeight(preview.height, reserveFooter);
1645
+ return (
1646
+ <FloatingPaneWrapper
1647
+ paneId={pane.instance.instanceId}
1648
+ title={getPaneTitle(pane)}
1649
+ x={preview.x}
1650
+ y={preview.y}
1651
+ width={preview.width}
1652
+ height={preview.height}
1653
+ zIndex={pane.floating?.zIndex ?? 50}
1654
+ focused={focused}
1655
+ showActions={showActions}
1656
+ footer={footer}
1657
+ onMouseDown={nativePaneChrome ? () => focusNativePane(pane.instance.instanceId) : undefined}
1658
+ onMouseMove={() => setHoveredPaneIfChanged(pane.instance.instanceId)}
1659
+ onHeaderMouseDown={nativePaneChrome ? (event) => startNativeFloatingDrag(pane.instance.instanceId, preview, event) : undefined}
1660
+ onHeaderMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
1661
+ onHeaderMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
1662
+ onHeaderContextMenu={nativePaneChrome && nativeContextMenu === true ? (event) => handleNativePaneContextMenu(pane.instance.instanceId, preview, event) : undefined}
1663
+ onActionMouseDown={(event) => handlePaneAction(pane.instance.instanceId, preview, event)}
1664
+ onCloseMouseDown={(event) => handleFloatingCloseMouseDown(pane.instance.instanceId, event)}
1665
+ onResizeMouseDown={nativePaneChrome ? (event) => startNativeFloatResize(pane.instance.instanceId, preview, event) : undefined}
1666
+ onResizeMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
1667
+ onResizeMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
1668
+ >
1669
+ <PaneContent
1670
+ component={pane.def.component}
1671
+ paneId={pane.instance.instanceId}
1672
+ paneType={pane.instance.paneId}
1673
+ focused={focused}
1674
+ width={bodyWidth}
1675
+ height={bodyHeight}
1676
+ onClose={handleFloatingClose}
1677
+ />
1678
+ </FloatingPaneWrapper>
1679
+ );
1680
+ }}
1681
+ </PaneFooterProvider>
1052
1682
  );
1053
1683
  })}
1054
1684
 
1055
- {dockLeafLayouts.length === 0 && floatingPanes.length === 0 && (
1056
- <box flexGrow={1} alignItems="center" justifyContent="center">
1057
- <text fg={colors.textDim}>No panes configured. Press Ctrl+P to get started.</text>
1058
- </box>
1059
- )}
1060
-
1061
1685
  {dockDividerLayouts.map((divider) => {
1062
1686
  const active = dividerPreview?.pathKey === divider.path.join(".");
1063
1687
  const rect = active ? dividerPreview.rect : divider.rect;
1064
1688
  return (
1065
- <box
1689
+ <Box
1066
1690
  key={`divider:${divider.path.join(".")}`}
1067
1691
  position="absolute"
1068
1692
  left={rect.x}
@@ -1071,57 +1695,59 @@ export function Shell({ pluginRegistry }: ShellProps) {
1071
1695
  height={rect.height}
1072
1696
  zIndex={active ? 2 : 1}
1073
1697
  backgroundColor={active ? colors.borderFocused : colors.border}
1698
+ {...(nativePaneChrome ? {
1699
+ "data-gloom-role": "dock-divider",
1700
+ "data-axis": divider.axis,
1701
+ "data-active": active ? "true" : "false",
1702
+ style: { "--divider-color": active ? colors.borderFocused : colors.border } as any,
1703
+ } : {})}
1704
+ onMouseDown={nativePaneChrome ? (event) => startNativeDividerDrag(divider, event) : undefined}
1705
+ onMouseDrag={nativePaneChrome ? handleNativeDrag : undefined}
1706
+ onMouseDragEnd={nativePaneChrome ? handleNativeDrag : undefined}
1074
1707
  />
1075
1708
  );
1076
1709
  })}
1077
1710
 
1078
1711
  {/* Focus border overlay — rendered on top of the focused pane */}
1079
1712
  {(() => {
1080
- if (!state.focusedPaneId) return null;
1713
+ if (!focusedPaneId) return null;
1714
+ if (nativePaneChrome) return null;
1081
1715
  // Hide border when command bar or dialog is open, but keep it when just the pane menu is open
1082
1716
  if (overlayOpen && !menuState) return null;
1083
1717
  let rect: { x: number; y: number; width: number; height: number } | null = null;
1084
1718
  let z = 3;
1085
1719
  // Check floating panes first (they render on top of docked panes)
1086
- const floatingPane = floatingPanes.find((p) => p.instance.instanceId === state.focusedPaneId);
1720
+ const floatingPane = visibleFloatingPanes.find((entry) => entry.pane.instance.instanceId === focusedPaneId);
1087
1721
  if (floatingPane) {
1088
- rect = dragFloatingRect?.paneId === floatingPane.instance.instanceId
1089
- ? dragFloatingRect.rect
1090
- : floatingPane.floating!;
1091
- z = (floatingPane.floating?.zIndex ?? 50) + 1;
1722
+ rect = dragFloatingRect?.paneId === floatingPane.pane.instance.instanceId
1723
+ ? constrainFloatingRectToBounds(dragFloatingRect.rect, width, contentHeight)
1724
+ : floatingPane.rect;
1725
+ z = (floatingPane.pane.floating?.zIndex ?? 50) + 1;
1092
1726
  } else {
1093
1727
  // Check docked panes
1094
- const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === state.focusedPaneId);
1728
+ const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === focusedPaneId);
1095
1729
  if (dockedLeaf) {
1096
1730
  rect = dockedLeaf.rect;
1097
1731
  }
1098
1732
  }
1099
1733
  if (!rect || rect.height < 2) return null;
1100
1734
  const bc = colors.borderFocused;
1101
- const isFloating = !!floatingPane;
1102
1735
  const bodyTop = rect.y + 1; // below header (header renders its own border edges)
1103
- const bodyH = rect.height - 2; // rows between header and bottom edge
1736
+ const bodyH = rect.height - 2; // rows between header and footer
1104
1737
  return (
1105
1738
  <>
1106
1739
  {/* Left edge — body only */}
1107
1740
  {bodyH > 0 && (
1108
- <box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1109
- <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1110
- </box>
1741
+ <Box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1742
+ <Text fg={bc} selectable={false}>{"│".repeat(bodyH)}</Text>
1743
+ </Box>
1111
1744
  )}
1112
1745
  {/* Right edge — body only */}
1113
1746
  {bodyH > 0 && (
1114
- <box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1115
- <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1116
- </box>
1747
+ <Box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1748
+ <Text fg={bc} selectable={false}>{"│".repeat(bodyH)}</Text>
1749
+ </Box>
1117
1750
  )}
1118
- {/* Bottom edge — for floating, leave last 2 chars for resize handle (rendered by FloatingPaneWrapper) */}
1119
- <box key="focus-b" position="absolute" left={rect.x} top={rect.y + rect.height - 1} width={isFloating ? Math.max(0, rect.width - 2) : rect.width} height={1} zIndex={z}>
1120
- <text fg={bc} selectable={false}>{isFloating
1121
- ? `└${"─".repeat(Math.max(0, rect.width - 4))}─`
1122
- : `└${"─".repeat(Math.max(0, rect.width - 2))}┘`
1123
- }</text>
1124
- </box>
1125
1751
  </>
1126
1752
  );
1127
1753
  })()}
@@ -1132,7 +1758,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1132
1758
  && dockPreview.target.targetId === activeHoverOverlay.targetId
1133
1759
  && dockPreview.target.position === cell.position;
1134
1760
  return (
1135
- <box
1761
+ <Box
1136
1762
  key={`cell:${activeHoverOverlay.targetId}:${cell.position}`}
1137
1763
  position="absolute"
1138
1764
  left={cell.rect.x}
@@ -1151,9 +1777,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
1151
1777
  {activePaneDrag
1152
1778
  && activePaneDrag.mode === "docked"
1153
1779
  && dragFloatingRect?.paneId === activePaneDrag.paneId
1154
- && !dockPreview
1780
+ && !effectiveDockPreview
1155
1781
  && (
1156
- <box
1782
+ <Box
1157
1783
  position="absolute"
1158
1784
  left={dragFloatingRect.rect.x}
1159
1785
  top={dragFloatingRect.rect.y}
@@ -1167,13 +1793,13 @@ export function Shell({ pluginRegistry }: ShellProps) {
1167
1793
  />
1168
1794
  )}
1169
1795
 
1170
- {dockPreview && (
1171
- <box
1796
+ {effectiveDockPreview && (
1797
+ <Box
1172
1798
  position="absolute"
1173
- left={dockPreview.rect.x}
1174
- top={dockPreview.rect.y}
1175
- width={dockPreview.rect.width}
1176
- height={dockPreview.rect.height}
1799
+ left={effectiveDockPreview.rect.x}
1800
+ top={effectiveDockPreview.rect.y}
1801
+ width={effectiveDockPreview.rect.width}
1802
+ height={effectiveDockPreview.rect.height}
1177
1803
  border
1178
1804
  borderStyle="single"
1179
1805
  borderColor={colors.borderFocused}
@@ -1183,36 +1809,52 @@ export function Shell({ pluginRegistry }: ShellProps) {
1183
1809
  )}
1184
1810
 
1185
1811
  {menuState && (
1186
- <box
1812
+ <Box
1187
1813
  position="absolute"
1188
1814
  left={menuState.x}
1189
1815
  top={menuState.y}
1190
- width={MENU_WIDTH}
1816
+ width={menuState.width}
1191
1817
  height={menuState.items.length + 2}
1192
1818
  backgroundColor={colors.panel}
1193
1819
  border
1194
1820
  borderStyle="single"
1195
1821
  borderColor={colors.borderFocused}
1196
- zIndex={99}
1822
+ zIndex={MENU_Z_INDEX}
1197
1823
  flexDirection="column"
1198
1824
  >
1199
- {menuState.items.map((item) => (
1200
- <box
1201
- key={item.id}
1202
- height={1}
1203
- paddingLeft={1}
1204
- onMouseDown={(mouseEvent: any) => {
1205
- mouseEvent.stopPropagation();
1206
- mouseEvent.preventDefault();
1207
- setMenuState(null);
1208
- item.action();
1209
- }}
1210
- >
1211
- <text fg={colors.text}>{item.label}</text>
1212
- </box>
1213
- ))}
1214
- </box>
1825
+ {menuState.items.map((item) => {
1826
+ const hovered = hoveredMenuItemId === item.id;
1827
+ const innerWidth = Math.max(1, menuState.width - 2);
1828
+ const accelerator = item.accelerator ?? "";
1829
+ const acceleratorWidth = accelerator.length;
1830
+ const labelWidth = accelerator ? Math.max(1, innerWidth - acceleratorWidth - 1) : innerWidth;
1831
+ const label = truncateMenuText(item.label, labelWidth);
1832
+ const spacer = accelerator ? " ".repeat(Math.max(1, innerWidth - label.length - acceleratorWidth)) : "";
1833
+ const line = truncateMenuText(`${label}${spacer}${accelerator}`, innerWidth).padEnd(innerWidth, " ");
1834
+ return (
1835
+ <Box
1836
+ key={item.id}
1837
+ height={1}
1838
+ width={innerWidth}
1839
+ backgroundColor={hovered ? colors.selected : colors.panel}
1840
+ onMouseMove={() => setHoveredMenuItemId(item.id)}
1841
+ onMouseDown={(mouseEvent: any) => {
1842
+ mouseEvent.stopPropagation();
1843
+ mouseEvent.preventDefault();
1844
+ setMenuState(null);
1845
+ setHoveredMenuItemId(null);
1846
+ item.action();
1847
+ }}
1848
+ data-gloom-interactive="true"
1849
+ >
1850
+ <Text fg={hovered ? colors.selectedText : colors.text}>
1851
+ {line}
1852
+ </Text>
1853
+ </Box>
1854
+ );
1855
+ })}
1856
+ </Box>
1215
1857
  )}
1216
- </box>
1858
+ </Box>
1217
1859
  );
1218
1860
  }