gloomberb 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +68 -121
  3. package/bin/gloomberb +3 -0
  4. package/package.json +13 -4
  5. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  6. package/src/app.tsx +1330 -0
  7. package/src/brokers/sync-broker-instance.test.ts +100 -0
  8. package/src/brokers/sync-broker-instance.ts +341 -0
  9. package/src/cli/commands/plugins.ts +173 -0
  10. package/src/cli/commands/portfolio.ts +210 -0
  11. package/src/cli/commands/search.ts +120 -0
  12. package/src/cli/commands/ticker.ts +375 -0
  13. package/src/cli/commands/watchlist.ts +210 -0
  14. package/src/cli/context.ts +62 -0
  15. package/src/cli/errors.ts +13 -0
  16. package/src/cli/helpers.ts +85 -0
  17. package/src/cli/index.ts +143 -0
  18. package/src/cli/ticker-resolution.ts +39 -0
  19. package/src/cli/types.ts +15 -0
  20. package/src/cli.test.ts +361 -0
  21. package/src/components/chart/chart-data.ts +239 -0
  22. package/src/components/chart/chart-renderer.ts +1199 -0
  23. package/src/components/chart/chart-types.ts +82 -0
  24. package/src/components/chart/chart-viewport.ts +21 -0
  25. package/src/components/chart/chart.test.ts +439 -0
  26. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  27. package/src/components/chart/comparison-chart-data.ts +316 -0
  28. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  29. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  30. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  31. package/src/components/chart/cursor-motion.ts +48 -0
  32. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  33. package/src/components/chart/native/kitty-adapter.ts +12 -0
  34. package/src/components/chart/native/kitty-manager.ts +142 -0
  35. package/src/components/chart/native/kitty-protocol.ts +110 -0
  36. package/src/components/chart/native/kitty-support.ts +71 -0
  37. package/src/components/chart/native/kitty.test.ts +620 -0
  38. package/src/components/chart/native/renderer-selection.ts +24 -0
  39. package/src/components/chart/native/surface-manager.ts +225 -0
  40. package/src/components/chart/native/surface-sync.ts +34 -0
  41. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  42. package/src/components/chart/stock-chart.test.ts +107 -0
  43. package/src/components/chart/stock-chart.tsx +1556 -0
  44. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  45. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  46. package/src/components/command-bar/command-bar.tsx +4823 -0
  47. package/src/components/command-bar/command-registry.ts +261 -0
  48. package/src/components/command-bar/helpers.test.ts +224 -0
  49. package/src/components/command-bar/helpers.ts +371 -0
  50. package/src/components/command-bar/root-shortcuts.ts +158 -0
  51. package/src/components/command-bar/view-model.test.ts +152 -0
  52. package/src/components/command-bar/view-model.ts +185 -0
  53. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  54. package/src/components/command-bar/workflow-ops.ts +433 -0
  55. package/src/components/command-bar/workflow-types.ts +127 -0
  56. package/src/components/detail-feed-view.test.tsx +105 -0
  57. package/src/components/detail-feed-view.tsx +295 -0
  58. package/src/components/index.ts +32 -0
  59. package/src/components/layout/floating-pane.tsx +49 -0
  60. package/src/components/layout/header.tsx +129 -0
  61. package/src/components/layout/pane-header.tsx +44 -0
  62. package/src/components/layout/pane.tsx +49 -0
  63. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  64. package/src/components/layout/pixel-mouse.test.ts +40 -0
  65. package/src/components/layout/shell.test.tsx +625 -0
  66. package/src/components/layout/shell.tsx +1200 -0
  67. package/src/components/layout/status-bar.test.tsx +173 -0
  68. package/src/components/layout/status-bar.tsx +138 -0
  69. package/src/components/markdown-editor.tsx +134 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +310 -0
  97. package/src/data/config-store.ts +469 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +71 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +132 -0
  259. package/src/state/app-context.tsx +969 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +434 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +162 -0
  285. package/src/updater.ts +247 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -69
@@ -0,0 +1,1200 @@
1
+ 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";
5
+ import {
6
+ MIN_FLOAT_HEIGHT,
7
+ MIN_FLOAT_WIDTH,
8
+ addPaneFloating,
9
+ addPaneToLayout,
10
+ applyDrop,
11
+ bringToFront,
12
+ findDockLeaf,
13
+ floatAtRect,
14
+ floatPane,
15
+ getDockDividerLayouts,
16
+ getDockLeafLayouts,
17
+ getRememberedFloatingRect,
18
+ isPaneInLayout,
19
+ removePane,
20
+ resizeSplitAtPath,
21
+ resolveDocked,
22
+ resolveFloating,
23
+ simulateDrop,
24
+ swapPanes,
25
+ type DockLeafLayout,
26
+ type DropTarget,
27
+ type FloatingRect,
28
+ type LayoutBounds,
29
+ type ResolvedPane,
30
+ } from "../../plugins/pane-manager";
31
+ import type { PluginRegistry } from "../../plugins/registry";
32
+ import { removePaneInstances, createPaneInstance, type LayoutConfig } from "../../types/config";
33
+ import {
34
+ PaneInstanceProvider,
35
+ resolveCollectionForPane,
36
+ resolveTickerForPane,
37
+ useAppState,
38
+ } from "../../state/app-context";
39
+ import { colors } from "../../theme/colors";
40
+ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
41
+ import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
42
+ import { FloatingPaneWrapper } from "./floating-pane";
43
+ import { PaneWrapper } from "./pane";
44
+
45
+ interface ShellProps {
46
+ pluginRegistry: PluginRegistry;
47
+ }
48
+
49
+ interface HoverOverlay {
50
+ targetId: string;
51
+ rect: LayoutBounds;
52
+ cells: Array<{ position: "top" | "left" | "center" | "right" | "bottom"; rect: LayoutBounds }>;
53
+ }
54
+
55
+ type SnapGuidePosition =
56
+ | "left"
57
+ | "right"
58
+ | "top"
59
+ | "bottom"
60
+ | "top-left"
61
+ | "top-right"
62
+ | "bottom-left"
63
+ | "bottom-right";
64
+
65
+ interface SnapGuide {
66
+ position: SnapGuidePosition;
67
+ triggerRect: LayoutBounds;
68
+ previewRect: FloatingRect;
69
+ }
70
+
71
+ type DragPreview =
72
+ | {
73
+ kind: "dock";
74
+ target: DropTarget;
75
+ rect: LayoutBounds;
76
+ }
77
+ | {
78
+ kind: "snap";
79
+ position: SnapGuidePosition;
80
+ rect: FloatingRect;
81
+ };
82
+
83
+ interface ActionMenuState {
84
+ paneId: string;
85
+ x: number;
86
+ y: number;
87
+ items: Array<{ id: string; label: string; action: () => void }>;
88
+ }
89
+
90
+ interface FloatingPreviewRect {
91
+ paneId: string;
92
+ rect: FloatingRect;
93
+ }
94
+
95
+ interface DividerPreviewState {
96
+ pathKey: string;
97
+ rect: LayoutBounds;
98
+ ratio: number;
99
+ }
100
+
101
+ interface NativeFloatingPaneState {
102
+ paneId: string;
103
+ rect: FloatingRect;
104
+ zIndex: number;
105
+ }
106
+
107
+ interface NativeTransientOccluder {
108
+ id: string;
109
+ rect: LayoutBounds;
110
+ zIndex: number;
111
+ }
112
+
113
+ interface PaneDragReleaseResult {
114
+ nextLayout: LayoutConfig;
115
+ shouldShowGridlockTip: boolean;
116
+ }
117
+
118
+ type DragMode =
119
+ | {
120
+ type: "divider";
121
+ path: Array<0 | 1>;
122
+ axis: "horizontal" | "vertical";
123
+ startX: number;
124
+ startY: number;
125
+ startRatio: number;
126
+ bounds: LayoutBounds;
127
+ }
128
+ | {
129
+ type: "pane-drag";
130
+ paneId: string;
131
+ mode: "docked" | "floating";
132
+ startX: number;
133
+ startY: number;
134
+ origRect: FloatingRect;
135
+ }
136
+ | {
137
+ type: "float-resize";
138
+ paneId: string;
139
+ startX: number;
140
+ startY: number;
141
+ origRect: FloatingRect;
142
+ };
143
+
144
+ const HEADER_HEIGHT = 1;
145
+ const MENU_WIDTH = 18;
146
+ const PANE_DRAG_THRESHOLD = 2;
147
+
148
+ function pointInRect(rect: LayoutBounds, x: number, y: number): boolean {
149
+ return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
150
+ }
151
+
152
+ function isMeaningfulPaneDrag(startX: number, startY: number, currentX: number, currentY: number): boolean {
153
+ return Math.max(Math.abs(currentX - startX), Math.abs(currentY - startY)) >= PANE_DRAG_THRESHOLD;
154
+ }
155
+
156
+ function positionFloatingRectUnderPointer(
157
+ rect: FloatingRect,
158
+ drag: Extract<DragMode, { type: "pane-drag" }>,
159
+ pointerX: number,
160
+ pointerY: number,
161
+ totalWidth: number,
162
+ totalHeight: number,
163
+ ): FloatingRect {
164
+ const pointerOffsetX = Math.max(0, Math.min(rect.width - 1, drag.startX - drag.origRect.x));
165
+ const pointerOffsetY = Math.max(0, Math.min(rect.height - 1, drag.startY - drag.origRect.y));
166
+ return {
167
+ ...rect,
168
+ x: Math.max(0, Math.min(totalWidth - rect.width, pointerX - pointerOffsetX)),
169
+ y: Math.max(0, Math.min(totalHeight - rect.height, pointerY - pointerOffsetY)),
170
+ };
171
+ }
172
+
173
+ function centerRectWithin(parent: LayoutBounds, width: number, height: number): LayoutBounds {
174
+ const nextWidth = Math.max(1, Math.min(parent.width, width));
175
+ const nextHeight = Math.max(1, Math.min(parent.height, height));
176
+ return {
177
+ x: parent.x + Math.floor((parent.width - nextWidth) / 2),
178
+ y: parent.y + Math.floor((parent.height - nextHeight) / 2),
179
+ width: nextWidth,
180
+ height: nextHeight,
181
+ };
182
+ }
183
+
184
+ function compactOverlayRect(rect: LayoutBounds): LayoutBounds {
185
+ if (rect.width < 12 || rect.height < 7) return rect;
186
+ return centerRectWithin(
187
+ rect,
188
+ Math.max(9, Math.min(19, Math.floor(rect.width * 0.45))),
189
+ Math.max(5, Math.min(9, Math.floor(rect.height * 0.45))),
190
+ );
191
+ }
192
+
193
+ function makeOverlayCellRects(rect: LayoutBounds): HoverOverlay["cells"] {
194
+ const col1 = Math.max(1, Math.floor(rect.width / 3));
195
+ const col2 = Math.max(1, Math.floor((rect.width - col1) / 2));
196
+ const col3 = Math.max(1, rect.width - col1 - col2);
197
+ const row1 = Math.max(1, Math.floor(rect.height / 3));
198
+ const row2 = Math.max(1, Math.floor((rect.height - row1) / 2));
199
+ const row3 = Math.max(1, rect.height - row1 - row2);
200
+ const cols = [col1, col2, col3];
201
+ const rows = [row1, row2, row3];
202
+ const cells: HoverOverlay["cells"] = [];
203
+ let y = rect.y;
204
+ for (let rowIndex = 0; rowIndex < 3; rowIndex += 1) {
205
+ let x = rect.x;
206
+ for (let colIndex = 0; colIndex < 3; colIndex += 1) {
207
+ const position = (
208
+ rowIndex === 0 && colIndex === 1 ? "top"
209
+ : rowIndex === 1 && colIndex === 0 ? "left"
210
+ : rowIndex === 1 && colIndex === 1 ? "center"
211
+ : rowIndex === 1 && colIndex === 2 ? "right"
212
+ : rowIndex === 2 && colIndex === 1 ? "bottom"
213
+ : null
214
+ );
215
+ if (position) {
216
+ cells.push({
217
+ position,
218
+ rect: { x, y, width: cols[colIndex]!, height: rows[rowIndex]! },
219
+ });
220
+ }
221
+ x += cols[colIndex]!;
222
+ }
223
+ y += rows[rowIndex]!;
224
+ }
225
+ return cells;
226
+ }
227
+
228
+ function resolveHoverOverlay(
229
+ x: number,
230
+ y: number,
231
+ leaves: DockLeafLayout[],
232
+ draggedPaneId: string,
233
+ ): HoverOverlay | null {
234
+ const targetLeaf = leaves.find((leaf) => leaf.instanceId !== draggedPaneId && pointInRect(leaf.rect, x, y));
235
+ if (!targetLeaf) return null;
236
+ const overlayRect = compactOverlayRect(targetLeaf.rect);
237
+ if (!pointInRect(overlayRect, x, y)) return null;
238
+ return {
239
+ targetId: targetLeaf.instanceId,
240
+ rect: overlayRect,
241
+ cells: makeOverlayCellRects(overlayRect),
242
+ };
243
+ }
244
+
245
+ export function buildNativeWindowState(
246
+ dockedPaneIds: readonly string[],
247
+ floatingPanes: readonly NativeFloatingPaneState[],
248
+ dragFloatingRect: FloatingPreviewRect | null,
249
+ overlay: { open: boolean; width: number; contentHeight: number },
250
+ transientOccluders: readonly NativeTransientOccluder[] = [],
251
+ dockDividers: readonly DockDividerLayout[] = [],
252
+ ): { paneLayers: NativePaneLayer[]; occluders: NativeOccluder[] } {
253
+ const previewedFloatingPanes = floatingPanes.map((pane) => (
254
+ dragFloatingRect?.paneId === pane.paneId
255
+ ? { ...pane, rect: dragFloatingRect.rect }
256
+ : pane
257
+ ));
258
+
259
+ const paneLayers: NativePaneLayer[] = [
260
+ ...dockedPaneIds.map((paneId) => ({ paneId, zIndex: 0 })),
261
+ ...previewedFloatingPanes.map((pane) => ({ paneId: pane.paneId, zIndex: pane.zIndex })),
262
+ ];
263
+
264
+ const occluders: NativeOccluder[] = previewedFloatingPanes.map((pane) => ({
265
+ id: pane.paneId,
266
+ paneId: pane.paneId,
267
+ rect: {
268
+ x: pane.rect.x,
269
+ y: pane.rect.y + HEADER_HEIGHT,
270
+ width: pane.rect.width,
271
+ height: pane.rect.height,
272
+ },
273
+ zIndex: pane.zIndex,
274
+ }));
275
+
276
+ for (const divider of dockDividers) {
277
+ occluders.push({
278
+ id: `dock-divider:${divider.path.length > 0 ? divider.path.join(".") : "root"}`,
279
+ paneId: null,
280
+ rect: {
281
+ x: divider.rect.x,
282
+ y: divider.rect.y + HEADER_HEIGHT,
283
+ width: divider.rect.width,
284
+ height: divider.rect.height,
285
+ },
286
+ // Dividers sit above docked native panes but below floating windows.
287
+ zIndex: 1,
288
+ });
289
+ }
290
+
291
+ for (const occluder of transientOccluders) {
292
+ occluders.push({
293
+ id: occluder.id,
294
+ paneId: null,
295
+ rect: {
296
+ x: occluder.rect.x,
297
+ y: occluder.rect.y + HEADER_HEIGHT,
298
+ width: occluder.rect.width,
299
+ height: occluder.rect.height,
300
+ },
301
+ zIndex: occluder.zIndex,
302
+ });
303
+ }
304
+
305
+ if (overlay.open) {
306
+ occluders.push({
307
+ id: "overlay:global",
308
+ paneId: null,
309
+ rect: {
310
+ x: 0,
311
+ y: HEADER_HEIGHT,
312
+ width: overlay.width,
313
+ height: overlay.contentHeight,
314
+ },
315
+ zIndex: Number.MAX_SAFE_INTEGER,
316
+ });
317
+ }
318
+
319
+ return { paneLayers, occluders };
320
+ }
321
+
322
+ export function resolveNativeDockDividers(
323
+ dockDividers: readonly DockDividerLayout[],
324
+ dividerPreview: DividerPreviewState | null,
325
+ ): DockDividerLayout[] {
326
+ if (!dividerPreview) return [...dockDividers];
327
+ return dockDividers.map((divider) => (
328
+ divider.path.join(".") === dividerPreview.pathKey
329
+ ? { ...divider, rect: dividerPreview.rect, ratio: dividerPreview.ratio }
330
+ : divider
331
+ ));
332
+ }
333
+
334
+ export function finalizePaneDragRelease(
335
+ layout: LayoutConfig,
336
+ paneId: string,
337
+ previewRect: FloatingRect,
338
+ dockPreview: DragPreview | null,
339
+ ): PaneDragReleaseResult {
340
+ if (dockPreview?.kind === "dock") {
341
+ return {
342
+ nextLayout: applyDrop(layout, paneId, dockPreview.target),
343
+ shouldShowGridlockTip: false,
344
+ };
345
+ }
346
+
347
+ if (dockPreview?.kind === "snap") {
348
+ return {
349
+ nextLayout: floatAtRect(layout, paneId, dockPreview.rect),
350
+ shouldShowGridlockTip: true,
351
+ };
352
+ }
353
+
354
+ return {
355
+ nextLayout: floatAtRect(layout, paneId, previewRect),
356
+ shouldShowGridlockTip: false,
357
+ };
358
+ }
359
+
360
+ function makeSnapGuides(width: number, height: number): SnapGuide[] {
361
+ const halfWidth = Math.max(1, Math.floor(width / 2));
362
+ const halfHeight = Math.max(1, Math.floor(height / 2));
363
+ const cornerWidth = Math.max(8, Math.min(16, Math.floor(width * 0.2)));
364
+ const cornerHeight = Math.max(4, Math.min(8, Math.floor(height * 0.22)));
365
+ const edgeWidth = Math.max(6, Math.min(10, Math.floor(width * 0.1)));
366
+ const edgeHeight = Math.max(3, Math.min(6, Math.floor(height * 0.14)));
367
+
368
+ return [
369
+ {
370
+ position: "top-left",
371
+ triggerRect: { x: 0, y: 0, width: cornerWidth, height: cornerHeight },
372
+ previewRect: { x: 0, y: 0, width: halfWidth, height: halfHeight },
373
+ },
374
+ {
375
+ position: "top-right",
376
+ triggerRect: { x: Math.max(0, width - cornerWidth), y: 0, width: cornerWidth, height: cornerHeight },
377
+ previewRect: { x: Math.max(0, width - halfWidth), y: 0, width: halfWidth, height: halfHeight },
378
+ },
379
+ {
380
+ position: "bottom-left",
381
+ triggerRect: { x: 0, y: Math.max(0, height - cornerHeight), width: cornerWidth, height: cornerHeight },
382
+ previewRect: { x: 0, y: Math.max(0, height - halfHeight), width: halfWidth, height: halfHeight },
383
+ },
384
+ {
385
+ position: "bottom-right",
386
+ triggerRect: {
387
+ x: Math.max(0, width - cornerWidth),
388
+ y: Math.max(0, height - cornerHeight),
389
+ width: cornerWidth,
390
+ height: cornerHeight,
391
+ },
392
+ previewRect: {
393
+ x: Math.max(0, width - halfWidth),
394
+ y: Math.max(0, height - halfHeight),
395
+ width: halfWidth,
396
+ height: halfHeight,
397
+ },
398
+ },
399
+ {
400
+ position: "left",
401
+ triggerRect: { x: 0, y: cornerHeight, width: edgeWidth, height: Math.max(1, height - (cornerHeight * 2)) },
402
+ previewRect: { x: 0, y: 0, width: halfWidth, height },
403
+ },
404
+ {
405
+ position: "right",
406
+ triggerRect: {
407
+ x: Math.max(0, width - edgeWidth),
408
+ y: cornerHeight,
409
+ width: edgeWidth,
410
+ height: Math.max(1, height - (cornerHeight * 2)),
411
+ },
412
+ previewRect: { x: Math.max(0, width - halfWidth), y: 0, width: halfWidth, height },
413
+ },
414
+ {
415
+ position: "top",
416
+ triggerRect: { x: cornerWidth, y: 0, width: Math.max(1, width - (cornerWidth * 2)), height: edgeHeight },
417
+ previewRect: { x: 0, y: 0, width, height: halfHeight },
418
+ },
419
+ {
420
+ position: "bottom",
421
+ triggerRect: {
422
+ x: cornerWidth,
423
+ y: Math.max(0, height - edgeHeight),
424
+ width: Math.max(1, width - (cornerWidth * 2)),
425
+ height: edgeHeight,
426
+ },
427
+ previewRect: { x: 0, y: Math.max(0, height - halfHeight), width, height: halfHeight },
428
+ },
429
+ ];
430
+ }
431
+
432
+ function resolveSnapGuide(x: number, y: number, guides: SnapGuide[]): SnapGuide | null {
433
+ return guides.find((guide) => pointInRect(guide.triggerRect, x, y)) ?? null;
434
+ }
435
+
436
+ function resolveHeaderHitAreas(
437
+ width: number,
438
+ options: { floating: boolean },
439
+ ): {
440
+ actionStart: number | null;
441
+ closeStart: number | null;
442
+ } {
443
+ let rightEdge = width;
444
+ let closeStart: number | null = null;
445
+ let actionStart: number | null = null;
446
+
447
+ if (options.floating) {
448
+ closeStart = Math.max(0, rightEdge - PANE_HEADER_CLOSE.length);
449
+ rightEdge = closeStart;
450
+ }
451
+
452
+ actionStart = Math.max(0, rightEdge - PANE_HEADER_ACTION.length);
453
+
454
+ return { actionStart, closeStart };
455
+ }
456
+
457
+ function menuForPane(
458
+ pane: ResolvedPane,
459
+ layout: LayoutConfig,
460
+ width: number,
461
+ contentHeight: number,
462
+ pluginRegistry: PluginRegistry,
463
+ persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
464
+ focusPane: (paneId: string) => void,
465
+ openPaneSettings: (paneId: string) => void,
466
+ openLayoutMenu: () => void,
467
+ ) {
468
+ const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
469
+ if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
470
+ baseActions.push({
471
+ id: "settings",
472
+ label: "Settings",
473
+ action: () => openPaneSettings(pane.instance.instanceId),
474
+ });
475
+ }
476
+
477
+ if (pane.floating) {
478
+ baseActions.push({
479
+ id: "dock",
480
+ label: "Dock",
481
+ action: () => {
482
+ persistLayout(applyDrop(layout, pane.instance.instanceId, { kind: "frame", edge: "right" }));
483
+ focusPane(pane.instance.instanceId);
484
+ },
485
+ });
486
+ baseActions.push({
487
+ id: "front",
488
+ label: "Bring To Front",
489
+ action: () => persistLayout(bringToFront(layout, pane.instance.instanceId), { pushHistory: false }),
490
+ });
491
+ } else {
492
+ baseActions.push({
493
+ id: "float",
494
+ label: "Float",
495
+ action: () => {
496
+ persistLayout(floatPane(layout, pane.instance.instanceId, width, contentHeight, pane.def));
497
+ focusPane(pane.instance.instanceId);
498
+ },
499
+ });
500
+ }
501
+
502
+ baseActions.push({
503
+ id: "duplicate",
504
+ label: "Duplicate",
505
+ action: () => {
506
+ const duplicate = createPaneInstance(pane.instance.paneId, {
507
+ title: pane.instance.title,
508
+ binding: pane.instance.binding,
509
+ params: pane.instance.params,
510
+ settings: pane.instance.settings,
511
+ });
512
+ const nextLayout = pane.floating
513
+ ? floatAtRect(addPaneFloating(layout, duplicate, width, contentHeight, pane.def), duplicate.instanceId, {
514
+ x: Math.max(0, pane.floating.x + 2),
515
+ y: Math.max(0, pane.floating.y + 1),
516
+ width: pane.floating.width,
517
+ height: pane.floating.height,
518
+ })
519
+ : addPaneToLayout(layout, duplicate, { relativeTo: pane.instance.instanceId, position: "right" });
520
+ persistLayout(nextLayout);
521
+ focusPane(duplicate.instanceId);
522
+ },
523
+ });
524
+ baseActions.push({ id: "swap", label: "Swap With...", action: openLayoutMenu });
525
+ baseActions.push({
526
+ id: "close",
527
+ label: "Close",
528
+ action: () => {
529
+ persistLayout(removePane(layout, pane.instance.instanceId));
530
+ },
531
+ });
532
+
533
+ return baseActions;
534
+ }
535
+
536
+ export function Shell({ pluginRegistry }: ShellProps) {
537
+ const { state, dispatch } = useAppState();
538
+ const renderer = useRenderer();
539
+ const { width, height } = useTerminalDimensions();
540
+ const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
541
+
542
+ const contentHeight = height - (state.statusBarVisible ? 2 : 1);
543
+ pluginRegistry.getTermSizeFn = () => ({ width, height: contentHeight });
544
+
545
+ const layout = state.config.layout;
546
+ const dialogOpen = useDialogState((dialog) => dialog.isOpen);
547
+ const [hoveredPaneId, setHoveredPaneId] = useState<string | null>(null);
548
+ const [menuState, setMenuState] = useState<ActionMenuState | null>(null);
549
+ const overlayOpen = state.commandBarOpen || dialogOpen || !!menuState;
550
+
551
+ const dragRef = useRef<DragMode | null>(null);
552
+ const [dragFloatingRect, setDragFloatingRect] = useState<{ paneId: string; rect: FloatingRect } | null>(null);
553
+ const [dragCursor, setDragCursor] = useState<{ x: number; y: number } | null>(null);
554
+ const [dividerPreview, setDividerPreview] = useState<DividerPreviewState | null>(null);
555
+ const [dockPreview, setDockPreview] = useState<DragPreview | null>(null);
556
+
557
+ const cancelActiveDrag = useCallback(() => {
558
+ dragRef.current = null;
559
+ setDragFloatingRect(null);
560
+ setDragCursor(null);
561
+ setDividerPreview(null);
562
+ setDockPreview(null);
563
+ }, []);
564
+
565
+ useKeyboard((event) => {
566
+ if (event.name === "escape") {
567
+ if (!dragRef.current) return;
568
+ cancelActiveDrag();
569
+ return;
570
+ }
571
+ if (event.name !== "w" || !event.ctrl) return;
572
+ if (dragRef.current || overlayOpen || state.inputCaptured) return;
573
+ closeFocusedPane();
574
+ });
575
+
576
+ const disabledPaneIds = useMemo(() => {
577
+ const disabledPlugins = new Set(state.config.disabledPlugins);
578
+ const ids = new Set<string>();
579
+ for (const pluginId of disabledPlugins) {
580
+ for (const paneId of pluginRegistry.getPluginPaneIds(pluginId)) {
581
+ ids.add(paneId);
582
+ }
583
+ }
584
+ return ids;
585
+ }, [pluginRegistry, state.config.disabledPlugins]);
586
+
587
+ const hiddenInstanceIds = useMemo(() => (
588
+ layout.instances
589
+ .filter((instance) => disabledPaneIds.has(instance.paneId))
590
+ .map((instance) => instance.instanceId)
591
+ ), [disabledPaneIds, layout.instances]);
592
+
593
+ const visibleLayout = useMemo(
594
+ () => (hiddenInstanceIds.length > 0 ? removePaneInstances(layout, hiddenInstanceIds) : layout),
595
+ [hiddenInstanceIds, layout],
596
+ );
597
+
598
+ const persistLayout = useCallback((nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => {
599
+ const layouts = state.config.layouts.map((savedLayout, index) => (
600
+ index === state.config.activeLayoutIndex ? { ...savedLayout, layout: nextLayout } : savedLayout
601
+ ));
602
+ if (options?.pushHistory !== false) {
603
+ dispatch({ type: "PUSH_LAYOUT_HISTORY" });
604
+ }
605
+ dispatch({ type: "UPDATE_LAYOUT", layout: nextLayout });
606
+ saveConfig({ ...state.config, layout: nextLayout, layouts }).catch(() => {});
607
+ }, [dispatch, state.config]);
608
+
609
+ const closeFocusedPane = useCallback(() => {
610
+ if (!state.focusedPaneId || !isPaneInLayout(visibleLayout, state.focusedPaneId)) return;
611
+ persistLayout(removePane(visibleLayout, state.focusedPaneId));
612
+ }, [persistLayout, state.focusedPaneId, visibleLayout]);
613
+
614
+ const focusPane = useCallback((paneId: string) => {
615
+ dispatch({ type: "FOCUS_PANE", paneId });
616
+ }, [dispatch]);
617
+
618
+ const openLayoutMenu = useCallback(() => {
619
+ pluginRegistry.openCommandBarFn("LAY ");
620
+ }, [pluginRegistry]);
621
+
622
+ const openPaneSettings = useCallback((paneId: string) => {
623
+ pluginRegistry.openPaneSettingsFn(paneId);
624
+ setMenuState(null);
625
+ }, [pluginRegistry]);
626
+
627
+ const bounds = useMemo<LayoutBounds>(() => ({ x: 0, y: 0, width, height: contentHeight }), [contentHeight, width]);
628
+ const dockedPanes = useMemo(
629
+ () => resolveDocked(visibleLayout, pluginRegistry.panes).filter((pane) => !disabledPaneIds.has(pane.def.id)),
630
+ [disabledPaneIds, pluginRegistry.panes, visibleLayout],
631
+ );
632
+ const floatingPanes = useMemo(
633
+ () => resolveFloating(visibleLayout, pluginRegistry.panes).filter((pane) => !disabledPaneIds.has(pane.def.id)),
634
+ [disabledPaneIds, pluginRegistry.panes, visibleLayout],
635
+ );
636
+ const paneMap = useMemo(() => new Map([...dockedPanes, ...floatingPanes].map((pane) => [pane.instance.instanceId, pane])), [dockedPanes, floatingPanes]);
637
+ const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds), [bounds, visibleLayout]);
638
+ const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds), [bounds, visibleLayout]);
639
+ const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
640
+ const activePaneDrag = dragRef.current?.type === "pane-drag" ? dragRef.current : null;
641
+ const activeHoverOverlay = activePaneDrag && dragCursor
642
+ ? resolveHoverOverlay(dragCursor.x, dragCursor.y, dockLeafLayouts, activePaneDrag.paneId)
643
+ : null;
644
+ const nativeTransientOccluders = useMemo<NativeTransientOccluder[]>(() => {
645
+ const occluders: NativeTransientOccluder[] = [];
646
+
647
+ if (activeHoverOverlay) {
648
+ for (const cell of activeHoverOverlay.cells) {
649
+ occluders.push({
650
+ id: `drag-hover:${activeHoverOverlay.targetId}:${cell.position}`,
651
+ rect: cell.rect,
652
+ zIndex: cell.position === "center" ? 98 : 97,
653
+ });
654
+ }
655
+ }
656
+
657
+ if (activePaneDrag
658
+ && activePaneDrag.mode === "docked"
659
+ && dragFloatingRect?.paneId === activePaneDrag.paneId
660
+ && !dockPreview) {
661
+ occluders.push({
662
+ id: `drag-preview:${activePaneDrag.paneId}`,
663
+ rect: dragFloatingRect.rect,
664
+ zIndex: 95,
665
+ });
666
+ }
667
+
668
+ if (dockPreview) {
669
+ occluders.push({
670
+ id: `dock-preview:${dockPreview.kind}`,
671
+ rect: dockPreview.rect,
672
+ zIndex: 96,
673
+ });
674
+ }
675
+
676
+ return occluders;
677
+ }, [activeHoverOverlay, activePaneDrag, dockPreview, dragFloatingRect]);
678
+ const nativeDockDividers = useMemo(
679
+ () => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
680
+ [dividerPreview, dockDividerLayouts],
681
+ );
682
+ const nativeWindowState = useMemo(
683
+ () => buildNativeWindowState(
684
+ dockedPanes.map((pane) => pane.instance.instanceId),
685
+ floatingPanes.map((pane) => ({
686
+ paneId: pane.instance.instanceId,
687
+ rect: pane.floating!,
688
+ zIndex: pane.floating?.zIndex ?? 50,
689
+ })),
690
+ dragFloatingRect,
691
+ { open: overlayOpen, width, contentHeight },
692
+ nativeTransientOccluders,
693
+ nativeDockDividers,
694
+ ),
695
+ [contentHeight, dockedPanes, dragFloatingRect, floatingPanes, nativeDockDividers, nativeTransientOccluders, overlayOpen, width],
696
+ );
697
+
698
+ useEffect(() => {
699
+ nativeSurfaceManager.setWindowState(nativeWindowState);
700
+ }, [nativeSurfaceManager, nativeWindowState]);
701
+
702
+ const getPaneTitle = useCallback((pane: ResolvedPane): string => {
703
+ if (pane.instance.paneId === "ticker-detail") {
704
+ const ticker = resolveTickerForPane(state, pane.instance.instanceId);
705
+ if (ticker) return ticker;
706
+ const collectionId = resolveCollectionForPane(state, pane.instance.instanceId);
707
+ return state.config.portfolios.find((portfolio) => portfolio.id === collectionId)?.name
708
+ ?? state.config.watchlists.find((watchlist) => watchlist.id === collectionId)?.name
709
+ ?? pane.instance.title
710
+ ?? pane.def.name;
711
+ }
712
+ if (pane.instance.title) return pane.instance.title;
713
+ if (pane.instance.paneId === "portfolio-list") {
714
+ const collectionId = resolveCollectionForPane(state, pane.instance.instanceId);
715
+ return state.config.portfolios.find((portfolio) => portfolio.id === collectionId)?.name
716
+ ?? state.config.watchlists.find((watchlist) => watchlist.id === collectionId)?.name
717
+ ?? pane.def.name;
718
+ }
719
+ const ticker = resolveTickerForPane(state, pane.instance.instanceId);
720
+ return ticker ? `${pane.def.name}: ${ticker}` : pane.def.name;
721
+ }, [state]);
722
+
723
+ const openPaneMenu = useCallback((paneId: string, rect: LayoutBounds) => {
724
+ const pane = paneMap.get(paneId);
725
+ if (!pane) return;
726
+ focusPane(paneId);
727
+ const menuX = Math.max(0, Math.min(width - MENU_WIDTH, rect.x + Math.max(0, rect.width - MENU_WIDTH)));
728
+ const menuY = Math.max(0, Math.min(contentHeight - 1, rect.y + 1));
729
+ setMenuState({
730
+ paneId,
731
+ x: menuX,
732
+ y: menuY,
733
+ items: menuForPane(
734
+ pane,
735
+ visibleLayout,
736
+ width,
737
+ contentHeight,
738
+ pluginRegistry,
739
+ persistLayout,
740
+ focusPane,
741
+ openPaneSettings,
742
+ openLayoutMenu,
743
+ ),
744
+ });
745
+ }, [contentHeight, focusPane, openLayoutMenu, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
746
+
747
+ const handleFloatingClose = useCallback((paneId: string) => {
748
+ persistLayout(removePane(visibleLayout, paneId));
749
+ }, [persistLayout, visibleLayout]);
750
+
751
+ const handleMouse = useCallback((event: {
752
+ type: string;
753
+ x: number;
754
+ y: number;
755
+ stopPropagation: () => void;
756
+ preventDefault: () => void;
757
+ }) => {
758
+ const shellY = event.y - HEADER_HEIGHT;
759
+ if (shellY < 0) return;
760
+
761
+ if (event.type === "down") {
762
+ if (menuState) {
763
+ setMenuState(null);
764
+ }
765
+
766
+ for (const pane of [...floatingPanes].sort((a, b) => (b.floating?.zIndex ?? 50) - (a.floating?.zIndex ?? 50))) {
767
+ const rect = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
768
+ if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
769
+ const relativeX = event.x - rect.x;
770
+ const relativeY = shellY - rect.y;
771
+ const headerAreas = resolveHeaderHitAreas(rect.width, {
772
+ floating: true,
773
+ });
774
+ focusPane(pane.instance.instanceId);
775
+ if ((pane.floating?.zIndex ?? 50) < 999) {
776
+ persistLayout(bringToFront(visibleLayout, pane.instance.instanceId), { pushHistory: false });
777
+ }
778
+ if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
779
+ handleFloatingClose(pane.instance.instanceId);
780
+ event.stopPropagation();
781
+ event.preventDefault();
782
+ return;
783
+ }
784
+ if (relativeY === 0
785
+ && headerAreas.actionStart != null
786
+ && headerAreas.closeStart != null
787
+ && relativeX >= headerAreas.actionStart
788
+ && relativeX < headerAreas.closeStart) {
789
+ openPaneMenu(pane.instance.instanceId, rect);
790
+ event.stopPropagation();
791
+ event.preventDefault();
792
+ return;
793
+ }
794
+ if (relativeX >= rect.width - 2 && relativeY >= rect.height - 1) {
795
+ dragRef.current = {
796
+ type: "float-resize",
797
+ paneId: pane.instance.instanceId,
798
+ startX: event.x,
799
+ startY: shellY,
800
+ origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
801
+ };
802
+ setDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
803
+ event.stopPropagation();
804
+ event.preventDefault();
805
+ return;
806
+ }
807
+ if (relativeY === 0) {
808
+ dragRef.current = {
809
+ type: "pane-drag",
810
+ paneId: pane.instance.instanceId,
811
+ mode: "floating",
812
+ startX: event.x,
813
+ startY: shellY,
814
+ origRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
815
+ };
816
+ setDragFloatingRect({ paneId: pane.instance.instanceId, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });
817
+ event.stopPropagation();
818
+ event.preventDefault();
819
+ return;
820
+ }
821
+ event.stopPropagation();
822
+ event.preventDefault();
823
+ return;
824
+ }
825
+
826
+ for (const divider of dockDividerLayouts) {
827
+ if (!pointInRect(divider.rect, event.x, shellY)) continue;
828
+ dragRef.current = {
829
+ type: "divider",
830
+ path: divider.path,
831
+ axis: divider.axis,
832
+ startX: event.x,
833
+ startY: shellY,
834
+ startRatio: divider.ratio,
835
+ bounds: divider.bounds,
836
+ };
837
+ setDividerPreview({
838
+ pathKey: divider.path.join("."),
839
+ rect: divider.rect,
840
+ ratio: divider.ratio,
841
+ });
842
+ event.stopPropagation();
843
+ event.preventDefault();
844
+ return;
845
+ }
846
+
847
+ for (const leaf of dockLeafLayouts) {
848
+ if (!pointInRect(leaf.rect, event.x, shellY)) continue;
849
+ const pane = paneMap.get(leaf.instanceId);
850
+ if (!pane) continue;
851
+ const relativeX = event.x - leaf.rect.x;
852
+ const relativeY = shellY - leaf.rect.y;
853
+ const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
854
+ floating: false,
855
+ });
856
+ focusPane(leaf.instanceId);
857
+ if (relativeY === 0
858
+ && headerAreas.actionStart != null
859
+ && relativeX >= headerAreas.actionStart) {
860
+ openPaneMenu(leaf.instanceId, leaf.rect);
861
+ event.stopPropagation();
862
+ event.preventDefault();
863
+ return;
864
+ }
865
+ if (relativeY === 0) {
866
+ dragRef.current = {
867
+ type: "pane-drag",
868
+ paneId: leaf.instanceId,
869
+ mode: "docked",
870
+ startX: event.x,
871
+ startY: shellY,
872
+ origRect: { x: leaf.rect.x, y: leaf.rect.y, width: leaf.rect.width, height: leaf.rect.height },
873
+ };
874
+ event.stopPropagation();
875
+ event.preventDefault();
876
+ return;
877
+ }
878
+ event.stopPropagation();
879
+ event.preventDefault();
880
+ return;
881
+ }
882
+
883
+ return;
884
+ }
885
+
886
+ const drag = dragRef.current;
887
+ if (!drag) return;
888
+
889
+ if (event.type === "drag") {
890
+ if (drag.type === "divider") {
891
+ const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
892
+ const delta = drag.axis === "horizontal" ? event.x - drag.startX : shellY - drag.startY;
893
+ const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
894
+ const nextRect = drag.axis === "horizontal"
895
+ ? { x: drag.bounds.x + Math.round(drag.bounds.width * nextRatio) - 1, y: drag.bounds.y, width: 1, height: drag.bounds.height }
896
+ : { x: drag.bounds.x, y: drag.bounds.y + Math.round(drag.bounds.height * nextRatio) - 1, width: drag.bounds.width, height: 1 };
897
+ setDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
898
+ } else if (drag.type === "pane-drag") {
899
+ if (!isMeaningfulPaneDrag(drag.startX, drag.startY, event.x, shellY)) {
900
+ setDockPreview(null);
901
+ setDragCursor(null);
902
+ if (drag.mode === "floating") {
903
+ setDragFloatingRect({ paneId: drag.paneId, rect: drag.origRect });
904
+ }
905
+ event.stopPropagation();
906
+ event.preventDefault();
907
+ return;
908
+ }
909
+
910
+ const pane = paneMap.get(drag.paneId);
911
+ const nextRect = drag.mode === "docked"
912
+ ? positionFloatingRectUnderPointer(
913
+ getRememberedFloatingRect(visibleLayout, drag.paneId, width, contentHeight, pane?.def),
914
+ drag,
915
+ event.x,
916
+ shellY,
917
+ width,
918
+ contentHeight,
919
+ )
920
+ : {
921
+ x: Math.max(0, Math.min(width - drag.origRect.width, drag.origRect.x + (event.x - drag.startX))),
922
+ y: Math.max(0, Math.min(contentHeight - drag.origRect.height, drag.origRect.y + (shellY - drag.startY))),
923
+ width: drag.origRect.width,
924
+ height: drag.origRect.height,
925
+ };
926
+ setDragFloatingRect({ paneId: drag.paneId, rect: nextRect });
927
+ setDragCursor({ x: event.x, y: shellY });
928
+
929
+ const hoveredOverlay = resolveHoverOverlay(event.x, shellY, dockLeafLayouts, drag.paneId);
930
+ if (hoveredOverlay) {
931
+ const hoveredCell = hoveredOverlay.cells.find((cell) => pointInRect(cell.rect, event.x, shellY));
932
+ if (hoveredCell) {
933
+ const target: DropTarget = { kind: "leaf", targetId: hoveredOverlay.targetId, position: hoveredCell.position };
934
+ const simulation = simulateDrop(visibleLayout, drag.paneId, target, bounds);
935
+ if (simulation.previewRect) {
936
+ setDockPreview({ kind: "dock", target, rect: simulation.previewRect });
937
+ } else {
938
+ setDockPreview(null);
939
+ }
940
+ } else {
941
+ setDockPreview(null);
942
+ }
943
+ } else {
944
+ const snapGuide = resolveSnapGuide(event.x, shellY, snapGuides);
945
+ setDockPreview(snapGuide ? { kind: "snap", position: snapGuide.position, rect: snapGuide.previewRect } : null);
946
+ }
947
+ } else if (drag.type === "float-resize") {
948
+ setDragFloatingRect({
949
+ paneId: drag.paneId,
950
+ rect: {
951
+ x: drag.origRect.x,
952
+ y: drag.origRect.y,
953
+ width: Math.max(MIN_FLOAT_WIDTH, Math.min(width - drag.origRect.x, drag.origRect.width + (event.x - drag.startX))),
954
+ height: Math.max(MIN_FLOAT_HEIGHT, Math.min(contentHeight - drag.origRect.y, drag.origRect.height + (shellY - drag.startY))),
955
+ },
956
+ });
957
+ }
958
+ event.stopPropagation();
959
+ event.preventDefault();
960
+ return;
961
+ }
962
+
963
+ if (event.type === "up" || event.type === "drag-end") {
964
+ if (drag.type === "divider") {
965
+ if (dividerPreview) {
966
+ persistLayout(resizeSplitAtPath(visibleLayout, drag.path, dividerPreview.ratio));
967
+ }
968
+ setDividerPreview(null);
969
+ } else if (drag.type === "pane-drag") {
970
+ const movedEnough = isMeaningfulPaneDrag(drag.startX, drag.startY, event.x, shellY);
971
+ const previewRect = dragFloatingRect?.paneId === drag.paneId ? dragFloatingRect.rect : drag.origRect;
972
+ if (!movedEnough) {
973
+ setDockPreview(null);
974
+ setDragCursor(null);
975
+ setDragFloatingRect(null);
976
+ } else {
977
+ const releaseResult = finalizePaneDragRelease(visibleLayout, drag.paneId, previewRect, dockPreview);
978
+ persistLayout(releaseResult.nextLayout);
979
+ focusPane(drag.paneId);
980
+ if (releaseResult.shouldShowGridlockTip) {
981
+ dispatch({ type: "SHOW_GRIDLOCK_TIP" });
982
+ }
983
+ setDockPreview(null);
984
+ setDragCursor(null);
985
+ setDragFloatingRect(null);
986
+ }
987
+ } else if (drag.type === "float-resize") {
988
+ if (dragFloatingRect?.paneId === drag.paneId) {
989
+ persistLayout(floatAtRect(visibleLayout, drag.paneId, dragFloatingRect.rect));
990
+ }
991
+ setDragFloatingRect(null);
992
+ setDragCursor(null);
993
+ }
994
+ dragRef.current = null;
995
+ event.stopPropagation();
996
+ event.preventDefault();
997
+ }
998
+ }, [
999
+ bounds,
1000
+ closeFocusedPane,
1001
+ contentHeight,
1002
+ dividerPreview,
1003
+ dockDividerLayouts,
1004
+ dockLeafLayouts,
1005
+ dockPreview,
1006
+ dragFloatingRect,
1007
+ floatingPanes,
1008
+ focusPane,
1009
+ handleFloatingClose,
1010
+ menuState,
1011
+ openPaneMenu,
1012
+ paneMap,
1013
+ persistLayout,
1014
+ snapGuides,
1015
+ visibleLayout,
1016
+ width,
1017
+ ]);
1018
+
1019
+ return (
1020
+ <box flexDirection="row" flexGrow={1} height={contentHeight} onMouse={handleMouse}>
1021
+ {dockLeafLayouts.map((leaf) => {
1022
+ const pane = paneMap.get(leaf.instanceId);
1023
+ if (!pane) return null;
1024
+ const focused = state.focusedPaneId === leaf.instanceId && !overlayOpen;
1025
+ const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
1026
+ return (
1027
+ <box
1028
+ key={`dock:${leaf.instanceId}`}
1029
+ position="absolute"
1030
+ left={leaf.rect.x}
1031
+ top={leaf.rect.y}
1032
+ width={leaf.rect.width}
1033
+ height={leaf.rect.height}
1034
+ >
1035
+ <PaneWrapper
1036
+ title={getPaneTitle(pane)}
1037
+ focused={focused}
1038
+ width={leaf.rect.width}
1039
+ height={leaf.rect.height}
1040
+ showActions={showActions}
1041
+ onMouseMove={() => setHoveredPaneId(leaf.instanceId)}
1042
+ >
1043
+ <PaneInstanceProvider paneId={leaf.instanceId}>
1044
+ <pane.def.component
1045
+ paneId={pane.instance.instanceId}
1046
+ paneType={pane.instance.paneId}
1047
+ focused={focused}
1048
+ width={leaf.rect.width}
1049
+ height={leaf.rect.height - 1}
1050
+ />
1051
+ </PaneInstanceProvider>
1052
+ </PaneWrapper>
1053
+ </box>
1054
+ );
1055
+ })}
1056
+
1057
+ {floatingPanes.map((pane) => {
1058
+ const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1059
+ const focused = state.focusedPaneId === pane.instance.instanceId && !overlayOpen;
1060
+ const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1061
+ return (
1062
+ <FloatingPaneWrapper
1063
+ key={`float:${pane.instance.instanceId}`}
1064
+ title={getPaneTitle(pane)}
1065
+ x={preview.x}
1066
+ y={preview.y}
1067
+ width={preview.width}
1068
+ height={preview.height}
1069
+ zIndex={pane.floating?.zIndex ?? 50}
1070
+ focused={focused}
1071
+ showActions={showActions}
1072
+ onMouseMove={() => setHoveredPaneId(pane.instance.instanceId)}
1073
+ >
1074
+ <PaneInstanceProvider paneId={pane.instance.instanceId}>
1075
+ <pane.def.component
1076
+ paneId={pane.instance.instanceId}
1077
+ paneType={pane.instance.paneId}
1078
+ focused={focused}
1079
+ width={preview.width}
1080
+ height={preview.height - 1}
1081
+ close={() => handleFloatingClose(pane.instance.instanceId)}
1082
+ />
1083
+ </PaneInstanceProvider>
1084
+ </FloatingPaneWrapper>
1085
+ );
1086
+ })}
1087
+
1088
+ {dockLeafLayouts.length === 0 && floatingPanes.length === 0 && (
1089
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1090
+ <text fg={colors.textDim}>No panes configured. Press Ctrl+P to get started.</text>
1091
+ </box>
1092
+ )}
1093
+
1094
+ {dockDividerLayouts.map((divider) => {
1095
+ const active = dividerPreview?.pathKey === divider.path.join(".");
1096
+ const rect = active ? dividerPreview.rect : divider.rect;
1097
+ return (
1098
+ <box
1099
+ key={`divider:${divider.path.join(".")}`}
1100
+ position="absolute"
1101
+ left={rect.x}
1102
+ top={rect.y}
1103
+ width={rect.width}
1104
+ height={rect.height}
1105
+ zIndex={active ? 2 : 1}
1106
+ backgroundColor={active ? colors.borderFocused : colors.border}
1107
+ />
1108
+ );
1109
+ })}
1110
+
1111
+ {activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
1112
+ const active = dockPreview?.kind === "dock"
1113
+ && dockPreview.target.kind === "leaf"
1114
+ && dockPreview.target.targetId === activeHoverOverlay.targetId
1115
+ && dockPreview.target.position === cell.position;
1116
+ return (
1117
+ <box
1118
+ key={`cell:${activeHoverOverlay.targetId}:${cell.position}`}
1119
+ position="absolute"
1120
+ left={cell.rect.x}
1121
+ top={cell.rect.y}
1122
+ width={cell.rect.width}
1123
+ height={cell.rect.height}
1124
+ border
1125
+ borderStyle="single"
1126
+ borderColor={active ? colors.borderFocused : colors.border}
1127
+ backgroundColor={active ? colors.header : colors.panel}
1128
+ zIndex={cell.position === "center" ? 98 : 97}
1129
+ />
1130
+ );
1131
+ })}
1132
+
1133
+ {activePaneDrag
1134
+ && activePaneDrag.mode === "docked"
1135
+ && dragFloatingRect?.paneId === activePaneDrag.paneId
1136
+ && !dockPreview
1137
+ && (
1138
+ <box
1139
+ position="absolute"
1140
+ left={dragFloatingRect.rect.x}
1141
+ top={dragFloatingRect.rect.y}
1142
+ width={dragFloatingRect.rect.width}
1143
+ height={dragFloatingRect.rect.height}
1144
+ border
1145
+ borderStyle="single"
1146
+ borderColor={colors.borderFocused}
1147
+ backgroundColor={colors.panel}
1148
+ zIndex={95}
1149
+ />
1150
+ )}
1151
+
1152
+ {dockPreview && (
1153
+ <box
1154
+ position="absolute"
1155
+ left={dockPreview.rect.x}
1156
+ top={dockPreview.rect.y}
1157
+ width={dockPreview.rect.width}
1158
+ height={dockPreview.rect.height}
1159
+ border
1160
+ borderStyle="single"
1161
+ borderColor={colors.borderFocused}
1162
+ backgroundColor={colors.panel}
1163
+ zIndex={96}
1164
+ />
1165
+ )}
1166
+
1167
+ {menuState && (
1168
+ <box
1169
+ position="absolute"
1170
+ left={menuState.x}
1171
+ top={menuState.y}
1172
+ width={MENU_WIDTH}
1173
+ height={menuState.items.length + 2}
1174
+ backgroundColor={colors.panel}
1175
+ border
1176
+ borderStyle="single"
1177
+ borderColor={colors.borderFocused}
1178
+ zIndex={99}
1179
+ flexDirection="column"
1180
+ >
1181
+ {menuState.items.map((item) => (
1182
+ <box
1183
+ key={item.id}
1184
+ height={1}
1185
+ paddingLeft={1}
1186
+ onMouseDown={(mouseEvent: any) => {
1187
+ mouseEvent.stopPropagation();
1188
+ mouseEvent.preventDefault();
1189
+ setMenuState(null);
1190
+ item.action();
1191
+ }}
1192
+ >
1193
+ <text fg={colors.text}>{item.label}</text>
1194
+ </box>
1195
+ ))}
1196
+ </box>
1197
+ )}
1198
+ </box>
1199
+ );
1200
+ }