gloomberb 0.2.0 → 0.3.2

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/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1365 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1546 -0
  45. package/src/components/command-bar/command-bar.tsx +4841 -0
  46. package/src/components/command-bar/command-registry.ts +269 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +151 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +651 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/markdown-text.tsx +218 -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 +347 -0
  97. package/src/data/config-store.ts +507 -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 +55 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +350 -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 +460 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +270 -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 +224 -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 +162 -0
  259. package/src/state/app-context.tsx +981 -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 +444 -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 +282 -0
  285. package/src/updater.ts +303 -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 -68
@@ -0,0 +1,4841 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import { TextAttributes, type InputRenderable, type TextareaRenderable } from "@opentui/core";
4
+ import { useRenderer, useTerminalDimensions } from "@opentui/react";
5
+ import { Spinner } from "../spinner";
6
+ import { Button, NumberField, TextField } from "../ui";
7
+ import { ToggleList } from "../toggle-list";
8
+ import {
9
+ colors,
10
+ commandBarBg,
11
+ commandBarHeadingText,
12
+ commandBarHoverBg,
13
+ commandBarSelectedBg,
14
+ commandBarSelectedText,
15
+ commandBarSubtleText,
16
+ commandBarText,
17
+ } from "../../theme/colors";
18
+ import { getFocusedCollectionId, useAppState, useFocusedTicker } from "../../state/app-context";
19
+ import { fuzzyFilter } from "../../utils/fuzzy-search";
20
+ import { commands, getThemeOptions, matchPrefix, type Command } from "./command-registry";
21
+ import { applyTheme, getCurrentThemeId } from "../../theme/colors";
22
+ import { exportConfig, importConfig, resetAllData, saveConfig } from "../../data/config-store";
23
+ import type { DataProvider } from "../../types/data-provider";
24
+ import type { TickerRepository } from "../../data/ticker-repository";
25
+ import type { PluginRegistry } from "../../plugins/registry";
26
+ import type {
27
+ CommandDef,
28
+ PaneSettingField,
29
+ PaneTemplateCreateOptions,
30
+ PaneTemplateDef,
31
+ } from "../../types/plugin";
32
+ import {
33
+ DEFAULT_LAYOUT,
34
+ cloneLayout,
35
+ createPaneInstance,
36
+ findPaneInstance,
37
+ type LayoutConfig,
38
+ } from "../../types/config";
39
+ import { resolveBrokerConfigFields, type BrokerConfigField } from "../../types/broker";
40
+ import { buildIbkrConfigFromValues } from "../../plugins/ibkr/config";
41
+ import {
42
+ addPaneFloating,
43
+ addPaneToLayout,
44
+ dockPane,
45
+ floatPane,
46
+ getDockedPaneIds,
47
+ getLayoutPreview,
48
+ gridlockAllPanes,
49
+ isPaneDocked,
50
+ movePaneRelative,
51
+ removePane,
52
+ swapPanes,
53
+ } from "../../plugins/pane-manager";
54
+ import { CHART_RENDERER_PREFERENCES } from "../chart/chart-types";
55
+ import {
56
+ buildSections,
57
+ getEmptyState,
58
+ getRowPresentation,
59
+ resolveCommandBarMode,
60
+ truncateText,
61
+ } from "./view-model";
62
+ import {
63
+ createLocalTickerSearchCandidates,
64
+ normalizeTickerInput,
65
+ rankTickerSearchItems,
66
+ searchTickerCandidates,
67
+ upsertTickerFromSearchResult,
68
+ type TickerSearchCandidate,
69
+ } from "../../utils/ticker-search";
70
+ import { debugLog } from "../../utils/debug-log";
71
+ import type {
72
+ CommandBarFieldOption,
73
+ CommandBarFieldValue,
74
+ CommandBarMainSnapshot,
75
+ CommandBarPickerOption,
76
+ CommandBarRoute,
77
+ CommandBarWorkflowField,
78
+ CommandBarWorkflowRoute,
79
+ } from "./workflow-types";
80
+ import { parseRootShortcutIntent } from "./root-shortcuts";
81
+ import {
82
+ applyCollectionMembershipChange,
83
+ getCollectionTargetOptions,
84
+ resolvePreferredCollectionTarget,
85
+ resolveTickerInput,
86
+ resolveTickerListInput,
87
+ } from "./workflow-ops";
88
+ import {
89
+ buildGeneratedTemplateField,
90
+ coerceFieldBoolean,
91
+ coerceFieldString,
92
+ coerceFieldValues,
93
+ getCollectionCommandAction,
94
+ getCollectionCommandKind,
95
+ getCollectionCommandVerb,
96
+ getFirstVisibleFieldId,
97
+ getScreenFooterLeft,
98
+ getScreenFooterRight,
99
+ getVisibleWorkflowFields,
100
+ isCollectionCommand,
101
+ isRootParsedCommand,
102
+ isRouteCommandId,
103
+ isWorkflowTextField,
104
+ looksDestructiveCommand,
105
+ moveSelectedValue,
106
+ normalizeFieldOptions,
107
+ normalizeWizardFields,
108
+ routeCommandIdToScreen,
109
+ slugifyName,
110
+ summarizeError,
111
+ summarizePaneSettingValue,
112
+ summarizeWorkflowFieldValue,
113
+ toggleSelectedValue,
114
+ } from "./helpers";
115
+
116
+ interface CommandBarProps {
117
+ dataProvider: DataProvider;
118
+ tickerRepository: TickerRepository;
119
+ pluginRegistry: PluginRegistry;
120
+ quitApp: () => void;
121
+ onCheckForUpdates?: () => void | Promise<void>;
122
+ }
123
+
124
+ interface ResultItem {
125
+ id: string;
126
+ label: string;
127
+ detail: string;
128
+ category: string;
129
+ kind: "command" | "ticker" | "search" | "theme" | "plugin" | "action" | "info";
130
+ right?: string;
131
+ searchText?: string;
132
+ themeId?: string;
133
+ pluginToggle?: () => void | Promise<void>;
134
+ secondaryAction?: () => void | Promise<void>;
135
+ checked?: boolean;
136
+ current?: boolean;
137
+ disabled?: boolean;
138
+ action: () => void | Promise<void>;
139
+ }
140
+
141
+ type ListScreenKind = "root" | "mode" | "picker" | "pane-settings";
142
+
143
+ interface ListScreenState {
144
+ kind: ListScreenKind;
145
+ title: string;
146
+ subtitle?: string;
147
+ query: string;
148
+ selectedIdx: number;
149
+ hoveredIdx: number | null;
150
+ results: ResultItem[];
151
+ searching: boolean;
152
+ emptyLabel: string;
153
+ emptyDetail: string;
154
+ footerLeft: string;
155
+ footerRight: string;
156
+ }
157
+
158
+ type WorkflowStringValues = Record<string, string>;
159
+
160
+ const commandBarLog = debugLog.createLogger("command-bar");
161
+
162
+ function getInputRef(
163
+ store: Record<string, RefObject<InputRenderable | TextareaRenderable | null>>,
164
+ fieldId: string,
165
+ ): RefObject<InputRenderable | TextareaRenderable | null> {
166
+ if (!store[fieldId]) {
167
+ store[fieldId] = { current: null };
168
+ }
169
+ return store[fieldId]!;
170
+ }
171
+
172
+ function orderListResults(results: ResultItem[]): ResultItem[] {
173
+ return buildSections(results).flatMap((section) => section.items);
174
+ }
175
+
176
+ function getVisibleMultiSelectPickerOptions(
177
+ route: Extract<CommandBarRoute, { kind: "picker" }>,
178
+ ): CommandBarPickerOption[] {
179
+ if (route.pickerId !== "field-multi-select") {
180
+ return route.query
181
+ ? fuzzyFilter(route.options, route.query, (option) => `${option.label} ${option.detail || ""} ${option.description || ""}`)
182
+ : route.options;
183
+ }
184
+
185
+ const selectedValues = coerceFieldValues(route.payload?.selectedValues as CommandBarFieldValue | undefined);
186
+ const optionById = new Map(route.options.map((option) => [option.id, option]));
187
+ const knownSelectedValues = selectedValues.filter((value) => optionById.has(value));
188
+ const filteredOptions = route.query
189
+ ? fuzzyFilter(route.options, route.query, (option) => `${option.label} ${option.detail || ""} ${option.description || ""}`)
190
+ : route.options;
191
+
192
+ return filteredOptions.map((option) => {
193
+ const order = knownSelectedValues.indexOf(option.id);
194
+ const orderDescription = String(route.payload?.fieldType ?? "") === "ordered-multi-select" && order >= 0
195
+ ? `Order ${order + 1} of ${knownSelectedValues.length}.`
196
+ : "";
197
+ const description = [option.description || option.detail, orderDescription]
198
+ .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
199
+ .join(" ");
200
+ return {
201
+ ...option,
202
+ detail: description,
203
+ description,
204
+ };
205
+ });
206
+ }
207
+
208
+ export function CommandBar({
209
+ dataProvider,
210
+ tickerRepository,
211
+ pluginRegistry,
212
+ quitApp,
213
+ onCheckForUpdates,
214
+ }: CommandBarProps) {
215
+ const { state, dispatch } = useAppState();
216
+ const stateRef = useRef(state);
217
+ stateRef.current = state;
218
+ const { symbol: activeTickerSymbol, ticker: activeTickerData, financials: activeFinancials } = useFocusedTicker();
219
+ const renderer = useRenderer();
220
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
221
+ const [rootQuery, setRootQueryValue] = useState(state.commandBarQuery);
222
+ const rootQueryRef = useRef(rootQuery);
223
+ rootQueryRef.current = rootQuery;
224
+ const rootModeInfo = resolveCommandBarMode(rootQuery);
225
+ const [rootSelectedIdx, setRootSelectedIdx] = useState(0);
226
+ const [rootHoveredIdx, setRootHoveredIdx] = useState<number | null>(null);
227
+ const [rootSearching, setRootSearching] = useState(false);
228
+ const [rootProviderResults, setRootProviderResults] = useState<ResultItem[] | null>(null);
229
+ const [rootProviderResultsQuery, setRootProviderResultsQuery] = useState<string | null>(null);
230
+ const [routeStack, setRouteStack] = useState<CommandBarRoute[]>([]);
231
+ const [tickerSearchResults, setTickerSearchResults] = useState<ResultItem[]>([]);
232
+ const [tickerSearchPending, setTickerSearchPending] = useState(false);
233
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
234
+ const searchRequestIdRef = useRef(0);
235
+ const rootSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
236
+ const rootSearchRequestIdRef = useRef(0);
237
+ const rootLastSearchedQueryRef = useRef<string | null>(null);
238
+ const tickerSearchCacheRef = useRef<Map<string, TickerSearchCandidate[]>>(new Map());
239
+ const skipTickerSearchDebounceRef = useRef(false);
240
+ const lastMainBrowseRef = useRef<CommandBarMainSnapshot>({ query: "", selectedIdx: 0 });
241
+ const previousRootSelectionContextRef = useRef<{ query: string; mode: string } | null>(null);
242
+ const previousRootModeRef = useRef(rootModeInfo.kind);
243
+ const rootThemeBaseIdRef = useRef<string | null>(null);
244
+ const workflowInputRefs = useRef<Record<string, RefObject<InputRenderable | TextareaRenderable | null>>>({});
245
+ const visibleListStateRef = useRef<ListScreenState | null>(null);
246
+ const currentRoute = routeStack[routeStack.length - 1] ?? null;
247
+ const currentRouteRef = useRef<CommandBarRoute | null>(currentRoute);
248
+ currentRouteRef.current = currentRoute;
249
+ const activeCollectionId = getFocusedCollectionId(state);
250
+ const activePortfolio = state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId);
251
+
252
+ const restoreThemePreview = useCallback(() => {
253
+ const themeRoute = [...routeStack].reverse().find((route) => (
254
+ route.kind === "mode" && route.screen === "themes"
255
+ ));
256
+ if (themeRoute?.kind === "mode" && themeRoute.themeBaseId) {
257
+ applyTheme(themeRoute.themeBaseId);
258
+ dispatch({ type: "SET_THEME", theme: themeRoute.themeBaseId });
259
+ return;
260
+ }
261
+ const rootThemeBaseId = rootThemeBaseIdRef.current;
262
+ if (rootThemeBaseId && stateRef.current.config.theme !== rootThemeBaseId) {
263
+ applyTheme(rootThemeBaseId);
264
+ dispatch({ type: "SET_THEME", theme: rootThemeBaseId });
265
+ }
266
+ }, [dispatch, routeStack]);
267
+
268
+ const closeAll = useCallback((options?: { revertThemePreview?: boolean }) => {
269
+ if (options?.revertThemePreview !== false) {
270
+ restoreThemePreview();
271
+ }
272
+ dispatch({ type: "SET_COMMAND_BAR", open: false });
273
+ setRouteStack([]);
274
+ setRootSelectedIdx(0);
275
+ setRootHoveredIdx(null);
276
+ setTickerSearchResults([]);
277
+ setTickerSearchPending(false);
278
+ }, [dispatch, restoreThemePreview]);
279
+
280
+ const setRootQuery = useCallback((query: string) => {
281
+ rootQueryRef.current = query;
282
+ setRootQueryValue(query);
283
+ dispatch({ type: "SET_COMMAND_BAR_QUERY", query });
284
+ }, [dispatch]);
285
+
286
+ const pushRoute = useCallback((route: CommandBarRoute) => {
287
+ setRouteStack((current) => {
288
+ if (current.length === 0) {
289
+ return [{ ...route, restoreMain: lastMainBrowseRef.current }];
290
+ }
291
+ return [...current, route];
292
+ });
293
+ }, []);
294
+
295
+ const updateTopRoute = useCallback((updater: (route: CommandBarRoute) => CommandBarRoute) => {
296
+ setRouteStack((current) => {
297
+ if (current.length === 0) return current;
298
+ const next = [...current];
299
+ next[next.length - 1] = updater(next[next.length - 1]!);
300
+ return next;
301
+ });
302
+ }, []);
303
+
304
+ const popRoute = useCallback(() => {
305
+ if (!currentRoute) {
306
+ closeAll();
307
+ return;
308
+ }
309
+
310
+ if (currentRoute.kind === "mode" && currentRoute.screen === "themes" && currentRoute.themeBaseId) {
311
+ applyTheme(currentRoute.themeBaseId);
312
+ dispatch({ type: "SET_THEME", theme: currentRoute.themeBaseId });
313
+ }
314
+
315
+ if (routeStack.length === 1 && currentRoute.restoreMain) {
316
+ setRootQuery(currentRoute.restoreMain.query);
317
+ setRootSelectedIdx(currentRoute.restoreMain.selectedIdx);
318
+ setRootHoveredIdx(null);
319
+ }
320
+
321
+ setRouteStack((current) => current.slice(0, -1));
322
+ }, [closeAll, currentRoute, dispatch, routeStack.length, setRootQuery]);
323
+
324
+ const dismissCommandBar = useCallback(() => {
325
+ if (currentRoute) {
326
+ popRoute();
327
+ return;
328
+ }
329
+ closeAll();
330
+ }, [closeAll, currentRoute, popRoute]);
331
+
332
+ const setActiveCollection = useCallback((collectionId: string) => {
333
+ const currentState = stateRef.current;
334
+ const resolvePortfolioPane = (candidate?: string | null): string | null => {
335
+ if (!candidate) return null;
336
+ const instance = findPaneInstance(currentState.config.layout, candidate);
337
+ if (!instance) return null;
338
+ if (instance.paneId === "portfolio-list") return instance.instanceId;
339
+ if (instance.binding?.kind === "follow") return resolvePortfolioPane(instance.binding.sourceInstanceId);
340
+ return null;
341
+ };
342
+ const targetPaneId = resolvePortfolioPane(currentState.focusedPaneId)
343
+ ?? currentState.config.layout.instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId
344
+ ?? null;
345
+ if (!targetPaneId) return;
346
+ dispatch({ type: "UPDATE_PANE_STATE", paneId: targetPaneId, patch: { collectionId } });
347
+ }, [dispatch]);
348
+
349
+ const retargetDetailPane = useCallback((paneId: string, symbol: string) => {
350
+ const currentState = stateRef.current;
351
+ const targetPane = findPaneInstance(currentState.config.layout, paneId);
352
+ if (!targetPane || targetPane.paneId !== "ticker-detail") return;
353
+
354
+ const nextLayout = {
355
+ ...currentState.config.layout,
356
+ instances: currentState.config.layout.instances.map((instance) => (
357
+ instance.instanceId === targetPane.instanceId
358
+ ? { ...instance, title: symbol, binding: { kind: "fixed" as const, symbol } }
359
+ : instance
360
+ )),
361
+ };
362
+ const nextConfig = {
363
+ ...currentState.config,
364
+ layout: nextLayout,
365
+ layouts: currentState.config.layouts.map((savedLayout, index) => (
366
+ index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout: nextLayout } : savedLayout
367
+ )),
368
+ };
369
+ dispatch({ type: "UPDATE_LAYOUT", layout: nextLayout });
370
+ void saveConfig(nextConfig);
371
+ dispatch({ type: "FOCUS_PANE", paneId: targetPane.instanceId });
372
+ }, [dispatch]);
373
+
374
+ const openFixedTickerPane = useCallback((symbol: string) => {
375
+ pluginRegistry.pinTickerFn(symbol, { floating: true, paneType: "ticker-detail" });
376
+ }, [pluginRegistry]);
377
+
378
+ const focusTicker = useCallback((symbol: string, options?: { forceNewPane?: boolean }) => {
379
+ const currentState = stateRef.current;
380
+ const focusedPane = currentState.focusedPaneId
381
+ ? findPaneInstance(currentState.config.layout, currentState.focusedPaneId)
382
+ : null;
383
+ if (options?.forceNewPane) {
384
+ openFixedTickerPane(symbol);
385
+ return;
386
+ }
387
+
388
+ if (focusedPane?.paneId === "ticker-detail") {
389
+ retargetDetailPane(focusedPane.instanceId, symbol);
390
+ return;
391
+ }
392
+
393
+ openFixedTickerPane(symbol);
394
+ }, [openFixedTickerPane, retargetDetailPane]);
395
+
396
+ const persistLayoutChange = useCallback((nextLayout: LayoutConfig) => {
397
+ pluginRegistry.updateLayoutFn(nextLayout);
398
+ }, [pluginRegistry]);
399
+
400
+ const duplicatePane = useCallback((paneId: string) => {
401
+ const currentState = stateRef.current;
402
+ const pane = findPaneInstance(currentState.config.layout, paneId);
403
+ if (!pane) return;
404
+ const paneDef = pluginRegistry.panes.get(pane.paneId);
405
+ if (!paneDef) return;
406
+
407
+ const duplicate = createPaneInstance(pane.paneId, {
408
+ title: pane.title,
409
+ binding: pane.binding,
410
+ params: pane.params,
411
+ settings: pane.settings,
412
+ });
413
+
414
+ const { width, height } = pluginRegistry.getTermSizeFn();
415
+ const nextLayout = currentState.config.layout.floating.some((entry) => entry.instanceId === paneId)
416
+ ? addPaneFloating(currentState.config.layout, duplicate, width, height, paneDef)
417
+ : addPaneToLayout(currentState.config.layout, duplicate, { relativeTo: paneId, position: "right" });
418
+ persistLayoutChange(nextLayout);
419
+ dispatch({ type: "FOCUS_PANE", paneId: duplicate.instanceId });
420
+ }, [dispatch, persistLayoutChange, pluginRegistry]);
421
+
422
+ const openModeRoute = useCallback((
423
+ screen: "ticker-search" | "themes" | "plugins" | "layout" | "new-pane",
424
+ initialQuery = "",
425
+ payload?: Record<string, unknown>,
426
+ ) => {
427
+ if (screen === "ticker-search" && initialQuery.trim()) {
428
+ skipTickerSearchDebounceRef.current = true;
429
+ }
430
+ pushRoute({
431
+ kind: "mode",
432
+ screen,
433
+ query: initialQuery,
434
+ selectedIdx: 0,
435
+ hoveredIdx: null,
436
+ themeBaseId: screen === "themes" ? getCurrentThemeId() : undefined,
437
+ payload,
438
+ });
439
+ }, [pushRoute]);
440
+
441
+ const ensureRouteFieldFocus = useCallback((route: CommandBarWorkflowRoute) => {
442
+ const visibleFields = getVisibleWorkflowFields(route.fields, route.values);
443
+ const activeField = visibleFields.find((field) => field.id === route.activeFieldId) ?? visibleFields[0];
444
+ if (!activeField || !isWorkflowTextField(activeField)) return;
445
+ const inputRef = getInputRef(workflowInputRefs.current, activeField.id);
446
+ inputRef.current?.focus?.();
447
+ }, []);
448
+
449
+ const openWorkflowRoute = useCallback((route: CommandBarWorkflowRoute) => {
450
+ pushRoute({
451
+ ...route,
452
+ activeFieldId: route.activeFieldId ?? getFirstVisibleFieldId(route.fields, route.values),
453
+ error: null,
454
+ pending: false,
455
+ });
456
+ }, [pushRoute]);
457
+
458
+ const brokerChoices = useMemo(() => (
459
+ [...pluginRegistry.brokers.values()]
460
+ .filter((adapter) => adapter.configSchema.length > 0)
461
+ .map((adapter) => ({
462
+ id: adapter.id,
463
+ label: adapter.name,
464
+ description: `Create a new ${adapter.name} profile`,
465
+ adapter,
466
+ }))
467
+ ), [pluginRegistry.brokers]);
468
+
469
+ const buildBrokerWorkflow = useCallback((
470
+ selectorKey: "brokerType" | "source",
471
+ title: string,
472
+ subtitle: string | undefined,
473
+ submitLabel: string,
474
+ includeManualOption: boolean,
475
+ ): CommandBarWorkflowRoute | null => {
476
+ const options: CommandBarFieldOption[] = [];
477
+ if (includeManualOption) {
478
+ options.push({
479
+ label: "Create Manual Portfolio",
480
+ value: "manual",
481
+ description: "Add tickers and positions by hand",
482
+ });
483
+ }
484
+ options.push(...brokerChoices.map((choice) => ({
485
+ label: `Connect ${choice.label}`,
486
+ value: choice.id,
487
+ description: includeManualOption ? `Auto-import positions via ${choice.label}` : choice.description,
488
+ })));
489
+
490
+ if (options.length === 0) return null;
491
+
492
+ const fields: CommandBarWorkflowField[] = [{
493
+ id: selectorKey,
494
+ label: includeManualOption ? "Portfolio Source" : "Broker",
495
+ type: "select",
496
+ options,
497
+ required: true,
498
+ }];
499
+ const values: Record<string, CommandBarFieldValue> = {
500
+ [selectorKey]: options[0]!.value,
501
+ };
502
+
503
+ if (includeManualOption) {
504
+ fields.push({
505
+ id: "name",
506
+ label: "Portfolio Name",
507
+ type: "text",
508
+ placeholder: "Main Portfolio",
509
+ required: true,
510
+ dependsOn: [{ key: selectorKey, value: "manual" }],
511
+ });
512
+ values.name = "Main Portfolio";
513
+ }
514
+
515
+ for (const choice of brokerChoices) {
516
+ for (const field of choice.adapter.configSchema) {
517
+ const fieldId = `${choice.id}:${field.key}`;
518
+ const dependsOn = [
519
+ { key: selectorKey, value: choice.id },
520
+ ...(field.dependsOn
521
+ ? [{ key: `${choice.id}:${field.dependsOn.key}`, value: field.dependsOn.value }]
522
+ : []),
523
+ ];
524
+ if (field.type === "select") {
525
+ fields.push({
526
+ id: fieldId,
527
+ label: field.label,
528
+ type: "select",
529
+ placeholder: field.placeholder,
530
+ description: field.placeholder,
531
+ required: field.required,
532
+ options: normalizeFieldOptions(field.options),
533
+ dependsOn,
534
+ });
535
+ } else {
536
+ fields.push({
537
+ id: fieldId,
538
+ label: field.label,
539
+ type: field.type === "number"
540
+ ? "number"
541
+ : field.type === "password"
542
+ ? "password"
543
+ : "text",
544
+ placeholder: field.placeholder,
545
+ description: field.placeholder,
546
+ required: field.required,
547
+ dependsOn,
548
+ });
549
+ }
550
+ if (field.defaultValue) {
551
+ values[fieldId] = field.defaultValue;
552
+ } else if (field.type === "select" && field.options?.[0]?.value) {
553
+ values[fieldId] = field.options[0].value;
554
+ }
555
+ }
556
+ }
557
+
558
+ return {
559
+ kind: "workflow",
560
+ workflowId: `builtin:${title.toLowerCase().replace(/\s+/g, "-")}`,
561
+ title,
562
+ subtitle,
563
+ fields,
564
+ values,
565
+ activeFieldId: getFirstVisibleFieldId(fields, values),
566
+ submitLabel,
567
+ cancelLabel: "Back",
568
+ pendingLabel: "Connecting broker…",
569
+ pending: false,
570
+ error: null,
571
+ successBehavior: "close",
572
+ payload: {
573
+ kind: "builtin",
574
+ actionId: includeManualOption ? "new-portfolio" : "add-broker-account",
575
+ },
576
+ };
577
+ }, [brokerChoices]);
578
+
579
+ const extractBrokerWorkflowValues = useCallback((
580
+ values: Record<string, CommandBarFieldValue>,
581
+ selectorKey: "brokerType" | "source",
582
+ brokerId: string,
583
+ ): WorkflowStringValues => {
584
+ const next: WorkflowStringValues = {};
585
+ for (const [key, rawValue] of Object.entries(values)) {
586
+ if (!key.startsWith(`${brokerId}:`)) continue;
587
+ next[key.slice(brokerId.length + 1)] = coerceFieldString(rawValue);
588
+ }
589
+ next[selectorKey] = brokerId;
590
+ return next;
591
+ }, []);
592
+
593
+ const connectBrokerProfile = useCallback(async (
594
+ brokerId: string,
595
+ values: WorkflowStringValues,
596
+ ) => {
597
+ const adapter = pluginRegistry.brokers.get(brokerId);
598
+ if (!adapter) {
599
+ throw new Error(`Unknown broker "${brokerId}".`);
600
+ }
601
+
602
+ const requiredFields = resolveBrokerConfigFields(adapter, values).filter((field) => field.required);
603
+ for (const field of requiredFields) {
604
+ const nextValue = String(values[field.key] ?? "").trim();
605
+ if (!nextValue) {
606
+ throw new Error(`${field.label} is required.`);
607
+ }
608
+ }
609
+
610
+ const brokerValues = brokerId === "ibkr"
611
+ ? buildIbkrConfigFromValues(values)
612
+ : values;
613
+ const instance = await pluginRegistry.createBrokerInstanceFn(
614
+ brokerId,
615
+ adapter.name.trim(),
616
+ brokerValues as Record<string, unknown>,
617
+ );
618
+ await pluginRegistry.syncBrokerInstanceFn(instance.id);
619
+ const freshConfig = pluginRegistry.getConfigFn();
620
+ dispatch({ type: "SET_CONFIG", config: freshConfig });
621
+ const brokerTab = freshConfig.portfolios.find((portfolio) => portfolio.brokerInstanceId === instance.id);
622
+ if (brokerTab) setActiveCollection(brokerTab.id);
623
+ pluginRegistry.showToastFn("Connected! Positions will sync automatically.", { type: "success" });
624
+ }, [dispatch, pluginRegistry, setActiveCollection]);
625
+
626
+ const createManualPortfolio = useCallback(async (name: string) => {
627
+ const currentState = stateRef.current;
628
+ const trimmedName = name.trim();
629
+ if (!trimmedName) {
630
+ throw new Error("Portfolio name is required.");
631
+ }
632
+
633
+ const id = slugifyName(trimmedName, "portfolio");
634
+ const newPortfolio = {
635
+ id,
636
+ name: trimmedName,
637
+ currency: currentState.config.baseCurrency || "USD",
638
+ };
639
+ const nextConfig = {
640
+ ...currentState.config,
641
+ portfolios: [...currentState.config.portfolios, newPortfolio],
642
+ };
643
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
644
+ setActiveCollection(id);
645
+ await saveConfig(nextConfig);
646
+ pluginRegistry.showToastFn(`Created portfolio "${trimmedName}".`, { type: "success" });
647
+ }, [dispatch, pluginRegistry, setActiveCollection]);
648
+
649
+ const createWatchlist = useCallback(async (name: string) => {
650
+ const currentState = stateRef.current;
651
+ const trimmedName = name.trim();
652
+ if (!trimmedName) {
653
+ throw new Error("Watchlist name is required.");
654
+ }
655
+
656
+ const id = slugifyName(trimmedName, "watchlist");
657
+ const newWatchlist = { id, name: trimmedName };
658
+ const nextConfig = {
659
+ ...currentState.config,
660
+ watchlists: [...currentState.config.watchlists, newWatchlist],
661
+ };
662
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
663
+ setActiveCollection(id);
664
+ await saveConfig(nextConfig);
665
+ pluginRegistry.showToastFn(`Created watchlist "${trimmedName}".`, { type: "success" });
666
+ }, [dispatch, pluginRegistry, setActiveCollection]);
667
+
668
+ const deleteWatchlist = useCallback(async (watchlistId: string) => {
669
+ const currentState = stateRef.current;
670
+ const watchlist = currentState.config.watchlists.find((entry) => entry.id === watchlistId);
671
+ if (!watchlist) {
672
+ throw new Error("Watchlist not found.");
673
+ }
674
+
675
+ const nextConfig = {
676
+ ...currentState.config,
677
+ watchlists: currentState.config.watchlists.filter((entry) => entry.id !== watchlistId),
678
+ };
679
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
680
+ if (activeCollectionId === watchlistId) {
681
+ const fallback = nextConfig.portfolios[0]?.id || nextConfig.watchlists[0]?.id || "";
682
+ if (fallback) setActiveCollection(fallback);
683
+ }
684
+ await saveConfig(nextConfig);
685
+ pluginRegistry.showToastFn(`Deleted "${watchlist.name}".`, { type: "success" });
686
+ }, [activeCollectionId, dispatch, pluginRegistry, setActiveCollection]);
687
+
688
+ const deletePortfolio = useCallback(async (portfolioId: string) => {
689
+ const currentState = stateRef.current;
690
+ const portfolio = currentState.config.portfolios.find((entry) => entry.id === portfolioId);
691
+ if (!portfolio) {
692
+ throw new Error("Portfolio not found.");
693
+ }
694
+
695
+ const nextConfig = {
696
+ ...currentState.config,
697
+ portfolios: currentState.config.portfolios.filter((entry) => entry.id !== portfolioId),
698
+ };
699
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
700
+ if (activeCollectionId === portfolioId) {
701
+ const fallback = nextConfig.portfolios[0]?.id || nextConfig.watchlists[0]?.id || "";
702
+ if (fallback) setActiveCollection(fallback);
703
+ }
704
+ await saveConfig(nextConfig);
705
+ pluginRegistry.showToastFn(`Deleted "${portfolio.name}".`, { type: "success" });
706
+ }, [activeCollectionId, dispatch, pluginRegistry, setActiveCollection]);
707
+
708
+ const disconnectBrokerInstance = useCallback(async (instanceId: string) => {
709
+ const instance = stateRef.current.config.brokerInstances.find((entry) => entry.id === instanceId);
710
+ if (!instance) {
711
+ throw new Error("Broker profile not found.");
712
+ }
713
+ await pluginRegistry.removeBrokerInstanceFn(instanceId);
714
+ const freshConfig = pluginRegistry.getConfigFn();
715
+ dispatch({ type: "SET_CONFIG", config: freshConfig });
716
+ pluginRegistry.showToastFn(`Removed ${instance.label}.`, { type: "success" });
717
+ }, [dispatch, pluginRegistry]);
718
+
719
+ const openBuiltInWorkflow = useCallback((actionId: string) => {
720
+ switch (actionId) {
721
+ case "new-watchlist":
722
+ openWorkflowRoute({
723
+ kind: "workflow",
724
+ workflowId: "builtin:new-watchlist",
725
+ title: "New Watchlist",
726
+ subtitle: "Create a new watchlist inside the command bar.",
727
+ fields: [{
728
+ id: "name",
729
+ label: "Watchlist Name",
730
+ type: "text",
731
+ placeholder: "My Watchlist",
732
+ required: true,
733
+ }],
734
+ values: { name: "" },
735
+ activeFieldId: "name",
736
+ submitLabel: "Create Watchlist",
737
+ cancelLabel: "Back",
738
+ pendingLabel: "Creating watchlist…",
739
+ pending: false,
740
+ error: null,
741
+ successBehavior: "close",
742
+ payload: { kind: "builtin", actionId },
743
+ });
744
+ return;
745
+ case "new-layout":
746
+ case "rename-layout":
747
+ openWorkflowRoute({
748
+ kind: "workflow",
749
+ workflowId: `builtin:${actionId}`,
750
+ title: actionId === "new-layout" ? "New Layout" : "Rename Layout",
751
+ fields: [{
752
+ id: "name",
753
+ label: "Layout Name",
754
+ type: "text",
755
+ placeholder: actionId === "new-layout"
756
+ ? "Trading, Research, Overview"
757
+ : state.config.layouts[state.config.activeLayoutIndex]?.name || "Layout name",
758
+ required: true,
759
+ }],
760
+ values: { name: "" },
761
+ activeFieldId: "name",
762
+ submitLabel: actionId === "new-layout" ? "Create Layout" : "Rename Layout",
763
+ cancelLabel: "Back",
764
+ pendingLabel: actionId === "new-layout" ? "Creating layout…" : "Renaming layout…",
765
+ pending: false,
766
+ error: null,
767
+ successBehavior: "close",
768
+ payload: { kind: "builtin", actionId },
769
+ });
770
+ return;
771
+ case "new-portfolio": {
772
+ const workflow = buildBrokerWorkflow(
773
+ "source",
774
+ "New Portfolio",
775
+ "Choose a source for the new portfolio.",
776
+ "Create Portfolio",
777
+ true,
778
+ );
779
+ if (!workflow) {
780
+ pluginRegistry.showToastFn("No connectable brokers are installed.", { type: "info" });
781
+ return;
782
+ }
783
+ openWorkflowRoute(workflow);
784
+ return;
785
+ }
786
+ case "add-broker-account": {
787
+ const workflow = buildBrokerWorkflow(
788
+ "brokerType",
789
+ "Add Broker Account",
790
+ "Connect a new broker profile without leaving the command bar.",
791
+ "Connect Broker",
792
+ false,
793
+ );
794
+ if (!workflow) {
795
+ pluginRegistry.showToastFn("No connectable brokers are installed.", { type: "info" });
796
+ return;
797
+ }
798
+ openWorkflowRoute(workflow);
799
+ return;
800
+ }
801
+ default:
802
+ return;
803
+ }
804
+ }, [buildBrokerWorkflow, openWorkflowRoute, pluginRegistry, state.config.activeLayoutIndex, state.config.layouts]);
805
+
806
+ const openPickerRoute = useCallback((
807
+ route: CommandBarRoute,
808
+ ) => {
809
+ pushRoute(route);
810
+ }, [pushRoute]);
811
+
812
+ const openConfirmRoute = useCallback((route: CommandBarRoute) => {
813
+ pushRoute(route);
814
+ }, [pushRoute]);
815
+
816
+ const buildSharedWorkflowDeps = useCallback(() => ({
817
+ dataProvider,
818
+ tickerRepository,
819
+ pluginRegistry,
820
+ dispatch,
821
+ getState: () => stateRef.current,
822
+ }), [dataProvider, dispatch, pluginRegistry, tickerRepository]);
823
+
824
+ const openInlineConfirm = useCallback((options: {
825
+ confirmId: string;
826
+ title: string;
827
+ body: string[];
828
+ confirmLabel: string;
829
+ cancelLabel?: string;
830
+ tone?: "default" | "danger";
831
+ onConfirm: () => void | Promise<void>;
832
+ successBehavior?: "close" | "back" | "stay";
833
+ }) => {
834
+ openConfirmRoute({
835
+ kind: "confirm",
836
+ confirmId: options.confirmId,
837
+ title: options.title,
838
+ body: options.body,
839
+ confirmLabel: options.confirmLabel,
840
+ cancelLabel: options.cancelLabel || "Back",
841
+ tone: options.tone || "danger",
842
+ onConfirm: options.onConfirm,
843
+ pending: false,
844
+ error: null,
845
+ successBehavior: options.successBehavior || "close",
846
+ });
847
+ }, [openConfirmRoute]);
848
+
849
+ const resolvePluginCommandConfirm = useCallback((command: CommandDef) => {
850
+ const context = {
851
+ config: state.config,
852
+ layout: state.config.layout,
853
+ activeTicker: activeTickerSymbol,
854
+ activeCollectionId,
855
+ };
856
+ if (typeof command.confirm === "function") {
857
+ return command.confirm(context);
858
+ }
859
+ if (command.confirm) {
860
+ return command.confirm;
861
+ }
862
+ if (!looksDestructiveCommand(command)) {
863
+ return null;
864
+ }
865
+ return {
866
+ title: command.label,
867
+ body: [command.description || `Run ${command.label.toLowerCase()}?`],
868
+ confirmLabel: command.label,
869
+ cancelLabel: "Back",
870
+ tone: "danger" as const,
871
+ };
872
+ }, [activeCollectionId, activeTickerSymbol, state.config, state.config.layout]);
873
+
874
+ const openPaneSettingsRoute = useCallback((paneId: string) => {
875
+ const descriptor = pluginRegistry.resolvePaneSettings(paneId);
876
+ if (!descriptor) {
877
+ pluginRegistry.showToastFn("The focused pane has no settings.", { type: "info" });
878
+ return;
879
+ }
880
+ pushRoute({
881
+ kind: "pane-settings",
882
+ paneId: descriptor.paneId,
883
+ query: "",
884
+ selectedIdx: 0,
885
+ hoveredIdx: null,
886
+ error: null,
887
+ pendingFieldKey: null,
888
+ });
889
+ }, [pluginRegistry, pushRoute]);
890
+
891
+ const executeCollectionCommand = useCallback(async (
892
+ commandId: "add-watchlist" | "add-portfolio" | "remove-watchlist" | "remove-portfolio",
893
+ rawInput?: string,
894
+ explicitTargetId?: string | null,
895
+ ) => {
896
+ const stateForCommand = stateRef.current;
897
+ const kind = getCollectionCommandKind(commandId);
898
+ const action = getCollectionCommandAction(commandId);
899
+ const deps = buildSharedWorkflowDeps();
900
+ const resolvedTicker = await resolveTickerInput(
901
+ rawInput,
902
+ activeTickerSymbol,
903
+ activeCollectionId,
904
+ deps,
905
+ );
906
+
907
+ if (!resolvedTicker) {
908
+ openModeRoute("ticker-search", rawInput?.trim() || "", {
909
+ action: "collection-command",
910
+ commandId,
911
+ });
912
+ return;
913
+ }
914
+
915
+ const targetId = explicitTargetId
916
+ ?? resolvePreferredCollectionTarget(
917
+ stateForCommand,
918
+ kind,
919
+ activeCollectionId,
920
+ action,
921
+ resolvedTicker.ticker,
922
+ );
923
+
924
+ if (!targetId) {
925
+ const options = getCollectionTargetOptions(stateForCommand, kind, action, resolvedTicker.ticker)
926
+ .map((option) => ({
927
+ id: option.id,
928
+ label: option.label,
929
+ detail: option.description,
930
+ description: option.description,
931
+ }));
932
+ if (options.length === 0) {
933
+ pluginRegistry.showToastFn(
934
+ action === "add"
935
+ ? `No ${kind}s are available for ${resolvedTicker.symbol}.`
936
+ : `${resolvedTicker.symbol} is not in any ${kind}.`,
937
+ { type: "info" },
938
+ );
939
+ return;
940
+ }
941
+ openPickerRoute({
942
+ kind: "picker",
943
+ pickerId: "collection-target",
944
+ title: `${getCollectionCommandVerb(action)} ${resolvedTicker.symbol} ${action === "add" ? "to" : "from"} ${kind === "watchlist" ? "Watchlist" : "Portfolio"}`,
945
+ query: "",
946
+ selectedIdx: 0,
947
+ hoveredIdx: null,
948
+ options,
949
+ payload: {
950
+ commandId,
951
+ kind,
952
+ action,
953
+ symbol: resolvedTicker.symbol,
954
+ },
955
+ });
956
+ return;
957
+ }
958
+
959
+ const { changed } = await applyCollectionMembershipChange(
960
+ resolvedTicker.ticker,
961
+ kind,
962
+ action,
963
+ targetId,
964
+ deps,
965
+ );
966
+ const targetName = (kind === "watchlist"
967
+ ? stateForCommand.config.watchlists.find((entry) => entry.id === targetId)?.name
968
+ : stateForCommand.config.portfolios.find((entry) => entry.id === targetId)?.name) || targetId;
969
+ if (changed) {
970
+ pluginRegistry.showToastFn(
971
+ `${action === "add" ? "Added" : "Removed"} ${resolvedTicker.symbol} ${action === "add" ? "to" : "from"} "${targetName}".`,
972
+ { type: "success" },
973
+ );
974
+ } else {
975
+ pluginRegistry.showToastFn(
976
+ action === "add"
977
+ ? `${resolvedTicker.symbol} is already in "${targetName}".`
978
+ : `${resolvedTicker.symbol} is not in "${targetName}".`,
979
+ { type: "info" },
980
+ );
981
+ }
982
+ closeAll({ revertThemePreview: false });
983
+ }, [
984
+ activeCollectionId,
985
+ activeTickerSymbol,
986
+ buildSharedWorkflowDeps,
987
+ closeAll,
988
+ openModeRoute,
989
+ openPickerRoute,
990
+ pluginRegistry,
991
+ ]);
992
+
993
+ const runTickerSearchShortcut = useCallback(async (query?: string) => {
994
+ const trimmed = query?.trim() || "";
995
+ if (!trimmed) {
996
+ const inferred = normalizeTickerInput(activeTickerSymbol, query);
997
+ if (inferred) {
998
+ focusTicker(inferred);
999
+ closeAll({ revertThemePreview: false });
1000
+ return;
1001
+ }
1002
+ openModeRoute("ticker-search", "");
1003
+ return;
1004
+ }
1005
+
1006
+ const resolvedTicker = await resolveTickerInput(
1007
+ trimmed,
1008
+ activeTickerSymbol,
1009
+ activeCollectionId,
1010
+ buildSharedWorkflowDeps(),
1011
+ );
1012
+ if (resolvedTicker) {
1013
+ focusTicker(resolvedTicker.symbol);
1014
+ closeAll({ revertThemePreview: false });
1015
+ return;
1016
+ }
1017
+ openModeRoute("ticker-search", trimmed);
1018
+ }, [
1019
+ activeCollectionId,
1020
+ activeTickerSymbol,
1021
+ buildSharedWorkflowDeps,
1022
+ closeAll,
1023
+ focusTicker,
1024
+ openModeRoute,
1025
+ ]);
1026
+
1027
+ const normalizePaneSettingField = useCallback((
1028
+ paneId: string,
1029
+ field: PaneSettingField,
1030
+ currentValue: unknown,
1031
+ ): (
1032
+ | { mode: "toggle"; value: boolean }
1033
+ | { mode: "workflow"; route: CommandBarWorkflowRoute | null }
1034
+ | { mode: "picker"; route: Extract<CommandBarRoute, { kind: "picker" }> }
1035
+ ) => {
1036
+ switch (field.type) {
1037
+ case "toggle": {
1038
+ return {
1039
+ mode: "toggle" as const,
1040
+ value: currentValue === true,
1041
+ };
1042
+ }
1043
+ case "text": {
1044
+ return {
1045
+ mode: "workflow" as const,
1046
+ route: {
1047
+ kind: "workflow",
1048
+ workflowId: `pane-setting:${paneId}:${field.key}`,
1049
+ title: field.label,
1050
+ subtitle: field.description,
1051
+ fields: [{
1052
+ id: field.key,
1053
+ label: field.label,
1054
+ type: "text",
1055
+ placeholder: field.placeholder,
1056
+ required: false,
1057
+ description: field.description,
1058
+ }],
1059
+ values: {
1060
+ [field.key]: typeof currentValue === "string" ? currentValue : "",
1061
+ },
1062
+ activeFieldId: field.key,
1063
+ submitLabel: "Apply",
1064
+ cancelLabel: "Back",
1065
+ pendingLabel: "Applying setting…",
1066
+ pending: false,
1067
+ error: null,
1068
+ successBehavior: "back" as const,
1069
+ payload: {
1070
+ kind: "pane-setting" as const,
1071
+ actionId: field.key,
1072
+ },
1073
+ payloadMeta: {
1074
+ paneId,
1075
+ field,
1076
+ },
1077
+ },
1078
+ };
1079
+ }
1080
+ case "select": {
1081
+ return {
1082
+ mode: "picker" as const,
1083
+ route: {
1084
+ kind: "picker",
1085
+ pickerId: "field-select",
1086
+ title: field.label,
1087
+ query: "",
1088
+ selectedIdx: Math.max(0, field.options.findIndex((option) => option.value === currentValue)),
1089
+ hoveredIdx: null,
1090
+ options: field.options.map((option) => ({
1091
+ id: option.value,
1092
+ label: option.label,
1093
+ detail: option.description,
1094
+ description: option.description,
1095
+ })),
1096
+ payload: {
1097
+ parentKind: "pane-settings",
1098
+ paneId,
1099
+ field,
1100
+ fieldType: field.type,
1101
+ },
1102
+ },
1103
+ };
1104
+ }
1105
+ case "multi-select":
1106
+ case "ordered-multi-select": {
1107
+ return {
1108
+ mode: "picker" as const,
1109
+ route: {
1110
+ kind: "picker",
1111
+ pickerId: "field-multi-select",
1112
+ title: field.label,
1113
+ query: "",
1114
+ selectedIdx: 0,
1115
+ hoveredIdx: null,
1116
+ options: field.options.map((option) => ({
1117
+ id: option.value,
1118
+ label: option.label,
1119
+ detail: option.description,
1120
+ description: option.description,
1121
+ })),
1122
+ payload: {
1123
+ parentKind: "pane-settings",
1124
+ paneId,
1125
+ field,
1126
+ fieldType: field.type,
1127
+ selectedValues: Array.isArray(currentValue)
1128
+ ? currentValue.filter((entry): entry is string => typeof entry === "string")
1129
+ : [],
1130
+ },
1131
+ },
1132
+ };
1133
+ }
1134
+ default:
1135
+ return {
1136
+ mode: "workflow" as const,
1137
+ route: null,
1138
+ };
1139
+ }
1140
+ }, []);
1141
+
1142
+ function readWorkflowTextareaValue(fieldId: string, fallback = ""): string {
1143
+ const ref = getInputRef(workflowInputRefs.current, fieldId).current;
1144
+ const nextValue = (ref as TextareaRenderable | null)?.editBuffer?.getText?.();
1145
+ return typeof nextValue === "string" ? nextValue : fallback;
1146
+ }
1147
+
1148
+ function getWorkflowFieldStringValue(
1149
+ field: CommandBarWorkflowField,
1150
+ value: CommandBarFieldValue | undefined,
1151
+ ): string {
1152
+ return field.type === "textarea"
1153
+ ? readWorkflowTextareaValue(field.id, coerceFieldString(value))
1154
+ : coerceFieldString(value);
1155
+ }
1156
+
1157
+ function syncWorkflowTextareaField(fieldId: string, fallback = ""): string {
1158
+ const nextValue = readWorkflowTextareaValue(fieldId, fallback);
1159
+ updateWorkflowValue(fieldId, nextValue);
1160
+ return nextValue;
1161
+ }
1162
+
1163
+ function syncActiveWorkflowTextarea(route: CommandBarWorkflowRoute | null): void {
1164
+ if (route?.kind !== "workflow") return;
1165
+ const visibleFields = getVisibleWorkflowFields(route.fields, route.values);
1166
+ const activeField = visibleFields.find((field) => field.id === route.activeFieldId) ?? visibleFields[0];
1167
+ if (activeField?.type !== "textarea") return;
1168
+ syncWorkflowTextareaField(activeField.id, coerceFieldString(route.values[activeField.id]));
1169
+ }
1170
+
1171
+ const submitWorkflowRoute = useCallback(async (route: CommandBarWorkflowRoute) => {
1172
+ syncActiveWorkflowTextarea(route);
1173
+ const visibleFields = getVisibleWorkflowFields(route.fields, route.values);
1174
+ for (const field of visibleFields) {
1175
+ if (!field.required) continue;
1176
+ if (field.type === "toggle") continue;
1177
+ const value = route.values[field.id];
1178
+ if (field.type === "multi-select" || field.type === "ordered-multi-select") {
1179
+ if (coerceFieldValues(value).length === 0) {
1180
+ updateTopRoute((current) => current.kind === "workflow"
1181
+ ? { ...current, error: `${field.label} is required.` }
1182
+ : current);
1183
+ return;
1184
+ }
1185
+ continue;
1186
+ }
1187
+ if (!getWorkflowFieldStringValue(field, value).trim()) {
1188
+ updateTopRoute((current) => current.kind === "workflow"
1189
+ ? { ...current, error: `${field.label} is required.` }
1190
+ : current);
1191
+ return;
1192
+ }
1193
+ }
1194
+
1195
+ updateTopRoute((current) => current.kind === "workflow"
1196
+ ? { ...current, pending: true, error: null }
1197
+ : current);
1198
+
1199
+ try {
1200
+ switch (route.payload.kind) {
1201
+ case "builtin": {
1202
+ switch (route.payload.actionId) {
1203
+ case "new-watchlist":
1204
+ await createWatchlist(coerceFieldString(route.values.name));
1205
+ break;
1206
+ case "new-layout": {
1207
+ const name = coerceFieldString(route.values.name).trim();
1208
+ if (!name) throw new Error("Layout name is required.");
1209
+ dispatch({ type: "NEW_LAYOUT", name });
1210
+ pluginRegistry.showToastFn(`Created layout "${name}".`, { type: "success" });
1211
+ break;
1212
+ }
1213
+ case "rename-layout": {
1214
+ const name = coerceFieldString(route.values.name).trim();
1215
+ if (!name) throw new Error("Layout name is required.");
1216
+ dispatch({ type: "RENAME_LAYOUT", index: state.config.activeLayoutIndex, name });
1217
+ pluginRegistry.showToastFn(`Renamed layout to "${name}".`, { type: "success" });
1218
+ break;
1219
+ }
1220
+ case "new-portfolio": {
1221
+ const source = coerceFieldString(route.values.source);
1222
+ if (source === "manual") {
1223
+ await createManualPortfolio(coerceFieldString(route.values.name));
1224
+ } else {
1225
+ const values = extractBrokerWorkflowValues(route.values, "source", source);
1226
+ await connectBrokerProfile(source, values);
1227
+ }
1228
+ break;
1229
+ }
1230
+ case "add-broker-account": {
1231
+ const brokerId = coerceFieldString(route.values.brokerType);
1232
+ if (!brokerId) throw new Error("Broker is required.");
1233
+ const values = extractBrokerWorkflowValues(route.values, "brokerType", brokerId);
1234
+ await connectBrokerProfile(brokerId, values);
1235
+ break;
1236
+ }
1237
+ default:
1238
+ break;
1239
+ }
1240
+ break;
1241
+ }
1242
+ case "plugin-command": {
1243
+ const command = pluginRegistry.commands.get(route.payload.actionId);
1244
+ if (!command) throw new Error("Command not found.");
1245
+ const values: WorkflowStringValues = {};
1246
+ for (const field of visibleFields) {
1247
+ if (field.type === "toggle") {
1248
+ values[field.id] = coerceFieldBoolean(route.values[field.id]) ? "true" : "false";
1249
+ } else if (field.type === "multi-select" || field.type === "ordered-multi-select") {
1250
+ values[field.id] = coerceFieldValues(route.values[field.id]).join(",");
1251
+ } else {
1252
+ values[field.id] = getWorkflowFieldStringValue(field, route.values[field.id]);
1253
+ }
1254
+ }
1255
+ await command.execute(values);
1256
+ if (route.successLabel) {
1257
+ pluginRegistry.showToastFn(route.successLabel, { type: "success" });
1258
+ }
1259
+ break;
1260
+ }
1261
+ case "pane-template": {
1262
+ const template = pluginRegistry.paneTemplates.get(route.payload.actionId);
1263
+ if (!template) throw new Error("Pane template not found.");
1264
+ const argPlaceholder = String(route.payloadMeta?.argPlaceholder ?? "");
1265
+ const values: WorkflowStringValues = {};
1266
+ for (const field of visibleFields) {
1267
+ if (field.type === "toggle") {
1268
+ values[field.id] = coerceFieldBoolean(route.values[field.id]) ? "true" : "false";
1269
+ } else if (field.type === "multi-select" || field.type === "ordered-multi-select") {
1270
+ values[field.id] = coerceFieldValues(route.values[field.id]).join(",");
1271
+ } else {
1272
+ values[field.id] = getWorkflowFieldStringValue(field, route.values[field.id]);
1273
+ }
1274
+ }
1275
+ const createOptions: PaneTemplateCreateOptions = {
1276
+ values,
1277
+ arg: argPlaceholder ? values[argPlaceholder] : undefined,
1278
+ };
1279
+ await pluginRegistry.createPaneFromTemplateAsyncFn(template.id, createOptions);
1280
+ if (route.successLabel) {
1281
+ pluginRegistry.showToastFn(route.successLabel, { type: "success" });
1282
+ }
1283
+ break;
1284
+ }
1285
+ case "pane-setting": {
1286
+ const field = route.payloadMeta?.field as PaneSettingField | undefined;
1287
+ const paneId = route.payloadMeta?.paneId as string | undefined;
1288
+ if (!field || !paneId) throw new Error("Setting context is missing.");
1289
+ let nextValue: unknown;
1290
+ switch (field.type) {
1291
+ case "text":
1292
+ nextValue = coerceFieldString(route.values[field.key]);
1293
+ break;
1294
+ default:
1295
+ nextValue = coerceFieldString(route.values[field.key]);
1296
+ }
1297
+ await pluginRegistry.applyPaneSettingValueFn(paneId, field, nextValue);
1298
+ break;
1299
+ }
1300
+ default:
1301
+ break;
1302
+ }
1303
+
1304
+ if (route.successBehavior === "back") {
1305
+ setRouteStack((current) => current.slice(0, -1));
1306
+ return;
1307
+ }
1308
+ closeAll({ revertThemePreview: false });
1309
+ } catch (error) {
1310
+ updateTopRoute((current) => current.kind === "workflow"
1311
+ ? {
1312
+ ...current,
1313
+ pending: false,
1314
+ error: error instanceof Error ? error.message : "Could not complete that action.",
1315
+ }
1316
+ : current);
1317
+ return;
1318
+ }
1319
+
1320
+ updateTopRoute((current) => current.kind === "workflow"
1321
+ ? { ...current, pending: false, error: null }
1322
+ : current);
1323
+ }, [
1324
+ closeAll,
1325
+ connectBrokerProfile,
1326
+ createManualPortfolio,
1327
+ createWatchlist,
1328
+ dispatch,
1329
+ extractBrokerWorkflowValues,
1330
+ getWorkflowFieldStringValue,
1331
+ pluginRegistry,
1332
+ state.config.activeLayoutIndex,
1333
+ syncActiveWorkflowTextarea,
1334
+ updateTopRoute,
1335
+ ]);
1336
+
1337
+ const openPluginCommandWorkflow = useCallback((command: CommandDef) => {
1338
+ if (!command.wizard || command.wizard.length === 0) return;
1339
+ const normalized = normalizeWizardFields(command.wizard);
1340
+ openWorkflowRoute({
1341
+ kind: "workflow",
1342
+ workflowId: `plugin-command:${command.id}`,
1343
+ title: command.label,
1344
+ subtitle: command.description,
1345
+ description: normalized.description,
1346
+ fields: normalized.fields,
1347
+ values: normalized.initialValues,
1348
+ activeFieldId: getFirstVisibleFieldId(normalized.fields, normalized.initialValues),
1349
+ submitLabel: command.label,
1350
+ cancelLabel: "Back",
1351
+ pendingLabel: normalized.pendingLabel,
1352
+ successLabel: normalized.successLabel,
1353
+ pending: false,
1354
+ error: null,
1355
+ successBehavior: "close",
1356
+ payload: {
1357
+ kind: "plugin-command",
1358
+ actionId: command.id,
1359
+ },
1360
+ });
1361
+ }, [openWorkflowRoute]);
1362
+
1363
+ const shouldOpenTemplateConfig = useCallback((template: PaneTemplateDef, arg?: string): boolean => {
1364
+ if (template.wizard && template.wizard.length > 0) {
1365
+ if (!arg?.trim()) {
1366
+ return true;
1367
+ }
1368
+ const argPlaceholder = template.shortcut?.argPlaceholder;
1369
+ return template.wizard.some((step) => step.type === "textarea" || step.key !== argPlaceholder);
1370
+ }
1371
+ if (template.shortcut?.argPlaceholder === "ticker" || template.shortcut?.argPlaceholder === "tickers") {
1372
+ return !arg?.trim();
1373
+ }
1374
+ return false;
1375
+ }, []);
1376
+
1377
+ const openPaneTemplateWorkflow = useCallback((template: PaneTemplateDef, options?: { arg?: string }) => {
1378
+ const normalized = template.wizard && template.wizard.length > 0
1379
+ ? normalizeWizardFields(template.wizard)
1380
+ : { fields: [] as CommandBarWorkflowField[], description: [] as string[], initialValues: {} as Record<string, CommandBarFieldValue> };
1381
+ const generated = buildGeneratedTemplateField(template, activeTickerSymbol);
1382
+
1383
+ const fields = [...normalized.fields];
1384
+ const values: Record<string, CommandBarFieldValue> = { ...normalized.initialValues };
1385
+ if (generated.field && !fields.some((field) => field.id === generated.field!.id)) {
1386
+ fields.push(generated.field);
1387
+ if (generated.initialValue !== undefined) {
1388
+ values[generated.field.id] = generated.initialValue;
1389
+ }
1390
+ }
1391
+ if (options?.arg && template.shortcut?.argPlaceholder) {
1392
+ values[template.shortcut.argPlaceholder] = options.arg;
1393
+ }
1394
+
1395
+ openWorkflowRoute({
1396
+ kind: "workflow",
1397
+ workflowId: `pane-template:${template.id}`,
1398
+ title: template.label,
1399
+ subtitle: template.description,
1400
+ description: normalized.description,
1401
+ fields,
1402
+ values,
1403
+ activeFieldId: getFirstVisibleFieldId(fields, values),
1404
+ submitLabel: "Create Pane",
1405
+ cancelLabel: "Back",
1406
+ pendingLabel: normalized.pendingLabel ?? `Creating ${template.label.toLowerCase()}…`,
1407
+ successLabel: normalized.successLabel,
1408
+ pending: false,
1409
+ error: null,
1410
+ successBehavior: "close",
1411
+ payload: {
1412
+ kind: "pane-template",
1413
+ actionId: template.id,
1414
+ },
1415
+ payloadMeta: {
1416
+ argPlaceholder: template.shortcut?.argPlaceholder,
1417
+ },
1418
+ });
1419
+ }, [activeTickerSymbol, openWorkflowRoute]);
1420
+
1421
+ const openPaneTemplateDirect = useCallback(async (
1422
+ template: PaneTemplateDef,
1423
+ createOptions?: PaneTemplateCreateOptions,
1424
+ ) => {
1425
+ try {
1426
+ await pluginRegistry.createPaneFromTemplateAsyncFn(template.id, createOptions);
1427
+ closeAll({ revertThemePreview: false });
1428
+ } catch (error) {
1429
+ pluginRegistry.showToastFn(
1430
+ error instanceof Error ? error.message : `Could not create ${template.label.toLowerCase()}.`,
1431
+ { type: "error" },
1432
+ );
1433
+ }
1434
+ }, [closeAll, pluginRegistry]);
1435
+
1436
+ async function runPaneTemplateShortcut(
1437
+ template: PaneTemplateDef,
1438
+ rawArg?: string,
1439
+ ) {
1440
+ const trimmedArg = rawArg?.trim() || "";
1441
+ const argKind = template.shortcut?.argKind ?? template.shortcut?.argPlaceholder;
1442
+ if (argKind === "ticker") {
1443
+ const resolvedTicker = await resolveTickerInput(
1444
+ trimmedArg || undefined,
1445
+ activeTickerSymbol,
1446
+ activeCollectionId,
1447
+ buildSharedWorkflowDeps(),
1448
+ );
1449
+ if (!resolvedTicker) {
1450
+ openModeRoute("ticker-search", trimmedArg, {
1451
+ action: "pane-template",
1452
+ templateId: template.id,
1453
+ });
1454
+ return;
1455
+ }
1456
+ await openPaneTemplateDirect(template, {
1457
+ arg: resolvedTicker.symbol,
1458
+ symbol: resolvedTicker.symbol,
1459
+ ticker: resolvedTicker.ticker,
1460
+ });
1461
+ return;
1462
+ }
1463
+
1464
+ if (argKind === "ticker-list") {
1465
+ const trimmedList = trimmedArg || normalizeTickerInput(activeTickerSymbol, undefined) || "";
1466
+ if (!trimmedList || /[,\n]\s*$/.test(trimmedList)) {
1467
+ openPaneTemplateWorkflow(template, { arg: trimmedArg });
1468
+ return;
1469
+ }
1470
+ try {
1471
+ const symbols = await resolveTickerListInput(
1472
+ trimmedList,
1473
+ activeCollectionId,
1474
+ buildSharedWorkflowDeps(),
1475
+ );
1476
+ await openPaneTemplateDirect(template, {
1477
+ arg: trimmedList,
1478
+ symbols,
1479
+ });
1480
+ } catch {
1481
+ openPaneTemplateWorkflow(template, { arg: trimmedArg });
1482
+ }
1483
+ return;
1484
+ }
1485
+
1486
+ if (shouldOpenTemplateConfig(template, trimmedArg)) {
1487
+ openPaneTemplateWorkflow(template, { arg: trimmedArg });
1488
+ return;
1489
+ }
1490
+ await openPaneTemplateDirect(template, trimmedArg ? { arg: trimmedArg } : undefined);
1491
+ }
1492
+
1493
+ const openTickerDetail = useCallback((result: any, options?: { forceNewPane?: boolean }) => {
1494
+ (async () => {
1495
+ const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, result);
1496
+ dispatch({ type: "UPDATE_TICKER", ticker });
1497
+ if (created) {
1498
+ pluginRegistry.events.emit("ticker:added", { symbol: ticker.metadata.ticker, ticker });
1499
+ }
1500
+ focusTicker(ticker.metadata.ticker, options);
1501
+ closeAll({ revertThemePreview: false });
1502
+ })();
1503
+ }, [tickerRepository, dispatch, pluginRegistry.events, focusTicker, closeAll]);
1504
+
1505
+ const mapTickerSearchCandidateToResultItem = useCallback((candidate: TickerSearchCandidate): ResultItem => {
1506
+ if (candidate.kind === "ticker" && candidate.ticker) {
1507
+ return {
1508
+ id: candidate.id,
1509
+ label: candidate.label,
1510
+ detail: candidate.detail,
1511
+ right: candidate.right,
1512
+ category: candidate.category,
1513
+ kind: "ticker",
1514
+ secondaryAction: () => {
1515
+ focusTicker(candidate.ticker!.metadata.ticker, { forceNewPane: true });
1516
+ closeAll({ revertThemePreview: false });
1517
+ },
1518
+ action: () => {
1519
+ focusTicker(candidate.ticker!.metadata.ticker);
1520
+ closeAll({ revertThemePreview: false });
1521
+ },
1522
+ };
1523
+ }
1524
+
1525
+ return {
1526
+ id: candidate.id,
1527
+ label: candidate.label,
1528
+ detail: candidate.detail,
1529
+ right: candidate.right,
1530
+ category: candidate.category,
1531
+ kind: "search",
1532
+ secondaryAction: () => openTickerDetail(candidate.result!, { forceNewPane: true }),
1533
+ action: () => openTickerDetail(candidate.result!),
1534
+ };
1535
+ }, [closeAll, focusTicker, openTickerDetail]);
1536
+
1537
+ const buildTickerSearchResultItems = useCallback((candidates: TickerSearchCandidate[], query: string): ResultItem[] => (
1538
+ candidates.length > 0
1539
+ ? candidates.map((candidate) => mapTickerSearchCandidateToResultItem(candidate))
1540
+ : [{
1541
+ id: "no-results",
1542
+ label: `No matches for "${query}"`,
1543
+ detail: "Try a symbol, company name, exchange, or asset type",
1544
+ category: "Search",
1545
+ kind: "info",
1546
+ action: () => {},
1547
+ }]
1548
+ ), [mapTickerSearchCandidateToResultItem]);
1549
+
1550
+ const buildTickerSearchCacheKey = useCallback((
1551
+ query: string,
1552
+ brokerId?: string | null,
1553
+ brokerInstanceId?: string | null,
1554
+ ) => [query.trim().toUpperCase(), brokerId || "", brokerInstanceId || ""].join("|"), []);
1555
+
1556
+ const readTickerSearchCache = useCallback((
1557
+ query: string,
1558
+ brokerId?: string | null,
1559
+ brokerInstanceId?: string | null,
1560
+ ): TickerSearchCandidate[] | null => {
1561
+ const key = buildTickerSearchCacheKey(query, brokerId, brokerInstanceId);
1562
+ return tickerSearchCacheRef.current.get(key) ?? null;
1563
+ }, [buildTickerSearchCacheKey]);
1564
+
1565
+ const writeTickerSearchCache = useCallback((
1566
+ query: string,
1567
+ candidates: TickerSearchCandidate[],
1568
+ brokerId?: string | null,
1569
+ brokerInstanceId?: string | null,
1570
+ ) => {
1571
+ const key = buildTickerSearchCacheKey(query, brokerId, brokerInstanceId);
1572
+ tickerSearchCacheRef.current.set(key, candidates);
1573
+ while (tickerSearchCacheRef.current.size > 24) {
1574
+ const oldestKey = tickerSearchCacheRef.current.keys().next().value;
1575
+ if (!oldestKey) break;
1576
+ tickerSearchCacheRef.current.delete(oldestKey);
1577
+ }
1578
+ }, [buildTickerSearchCacheKey]);
1579
+
1580
+ useEffect(() => {
1581
+ tickerSearchCacheRef.current.clear();
1582
+ }, [state.tickers]);
1583
+
1584
+ const localTickerSearchResultItems = useCallback((query?: string, options?: {
1585
+ category?: string;
1586
+ limit?: number;
1587
+ }): ResultItem[] => {
1588
+ const items = query
1589
+ ? rankTickerSearchItems(createLocalTickerSearchCandidates(state.tickers.values()), query)
1590
+ : createLocalTickerSearchCandidates(state.tickers.values());
1591
+ return items
1592
+ .slice(0, options?.limit)
1593
+ .map((candidate) => ({
1594
+ ...mapTickerSearchCandidateToResultItem(candidate),
1595
+ category: options?.category ?? candidate.category,
1596
+ }));
1597
+ }, [mapTickerSearchCandidateToResultItem, state.tickers]);
1598
+
1599
+ const adaptTickerSearchRouteResult = useCallback((
1600
+ item: ResultItem,
1601
+ routePayload: Record<string, unknown> | undefined,
1602
+ ): ResultItem => {
1603
+ const routeAction = String(routePayload?.action ?? "");
1604
+ if (routeAction === "pane-template") {
1605
+ const templateId = String(routePayload?.templateId ?? "");
1606
+ const template = pluginRegistry.paneTemplates.get(templateId);
1607
+ if (!template) return item;
1608
+ return {
1609
+ ...item,
1610
+ action: () => { void runPaneTemplateShortcut(template, item.label); },
1611
+ };
1612
+ }
1613
+ if (routeAction === "collection-command") {
1614
+ const commandId = String(routePayload?.commandId ?? "");
1615
+ if (!isCollectionCommand(commandId)) return item;
1616
+ return {
1617
+ ...item,
1618
+ action: () => { void executeCollectionCommand(commandId, item.label); },
1619
+ };
1620
+ }
1621
+ return item;
1622
+ }, [executeCollectionCommand, pluginRegistry.paneTemplates, runPaneTemplateShortcut]);
1623
+
1624
+ const getPaneTemplateContext = useCallback(() => ({
1625
+ config: state.config,
1626
+ layout: state.config.layout,
1627
+ focusedPaneId: state.focusedPaneId,
1628
+ activeTicker: activeTickerSymbol,
1629
+ activeCollectionId,
1630
+ }), [activeCollectionId, activeTickerSymbol, state.config, state.focusedPaneId]);
1631
+
1632
+ const getAvailablePaneTemplates = useCallback((options?: PaneTemplateCreateOptions): PaneTemplateDef[] => {
1633
+ const disabledPlugins = new Set(state.config.disabledPlugins || []);
1634
+ const context = getPaneTemplateContext();
1635
+ return [...pluginRegistry.paneTemplates.values()]
1636
+ .filter((template) => {
1637
+ const pluginId = pluginRegistry.getPaneTemplatePluginId(template.id);
1638
+ if (pluginId && disabledPlugins.has(pluginId)) return false;
1639
+ if (!template.canCreate) return true;
1640
+ try {
1641
+ return template.canCreate(context, options);
1642
+ } catch (error) {
1643
+ commandBarLog.error("Pane template canCreate failed", {
1644
+ templateId: template.id,
1645
+ pluginId,
1646
+ options,
1647
+ error: summarizeError(error),
1648
+ });
1649
+ return false;
1650
+ }
1651
+ });
1652
+ }, [getPaneTemplateContext, pluginRegistry, state.config.disabledPlugins]);
1653
+
1654
+ const getAvailablePaneShortcutTemplates = useCallback((query: string): PaneTemplateDef[] => {
1655
+ const trimmed = query.trim();
1656
+ const upper = trimmed.toUpperCase();
1657
+ const disabledPlugins = new Set(state.config.disabledPlugins || []);
1658
+ const context = getPaneTemplateContext();
1659
+ return [...pluginRegistry.paneTemplates.values()].filter((template) => {
1660
+ const pluginId = pluginRegistry.getPaneTemplatePluginId(template.id);
1661
+ if (pluginId && disabledPlugins.has(pluginId)) return false;
1662
+ const prefix = template.shortcut?.prefix?.toUpperCase();
1663
+ if (!prefix) return false;
1664
+ if (upper !== prefix && !upper.startsWith(`${prefix} `)) return false;
1665
+ const arg = trimmed.slice(prefix.length).trim();
1666
+ const argKind = template.shortcut?.argKind ?? template.shortcut?.argPlaceholder;
1667
+ if (!template.canCreate) return true;
1668
+ try {
1669
+ const canCreate = template.canCreate(context, arg ? { arg } : undefined);
1670
+ if (canCreate) return true;
1671
+ if (!arg && !context.activeTicker && (argKind === "ticker" || argKind === "ticker-list")) {
1672
+ return true;
1673
+ }
1674
+ return false;
1675
+ } catch (error) {
1676
+ commandBarLog.error("Pane shortcut canCreate failed", {
1677
+ templateId: template.id,
1678
+ query,
1679
+ error: summarizeError(error),
1680
+ });
1681
+ return false;
1682
+ }
1683
+ });
1684
+ }, [getPaneTemplateContext, pluginRegistry, state.config.disabledPlugins]);
1685
+
1686
+ const rootShortcutIntent = useMemo(() => parseRootShortcutIntent({
1687
+ query: rootQuery,
1688
+ commands,
1689
+ paneTemplates: getAvailablePaneShortcutTemplates(rootQuery),
1690
+ activeTicker: activeTickerSymbol,
1691
+ }), [activeTickerSymbol, getAvailablePaneShortcutTemplates, rootQuery]);
1692
+
1693
+ const createPaneTemplateItem = useCallback((
1694
+ template: PaneTemplateDef,
1695
+ options?: {
1696
+ category?: string;
1697
+ createOptions?: PaneTemplateCreateOptions;
1698
+ showShortcut?: boolean;
1699
+ shortcutExecution?: boolean;
1700
+ },
1701
+ ): ResultItem => {
1702
+ const pluginId = pluginRegistry.getPaneTemplatePluginId(template.id);
1703
+ const pluginName = pluginId ? pluginRegistry.allPlugins.get(pluginId)?.name : null;
1704
+ const paneDef = pluginRegistry.panes.get(template.paneId);
1705
+ const shortcutLabel = template.shortcut
1706
+ ? [template.shortcut.prefix, template.shortcut.argPlaceholder && `<${template.shortcut.argPlaceholder}>`]
1707
+ .filter(Boolean)
1708
+ .join(" ")
1709
+ : null;
1710
+ const placementLabel = paneDef?.defaultMode === "floating" ? "float" : "dock";
1711
+ const arg = options?.createOptions?.arg;
1712
+
1713
+ const action = () => {
1714
+ if (options?.shortcutExecution && template.shortcut) {
1715
+ void runPaneTemplateShortcut(template, arg);
1716
+ return;
1717
+ }
1718
+ if (shouldOpenTemplateConfig(template, arg)) {
1719
+ openPaneTemplateWorkflow(template, { arg });
1720
+ return;
1721
+ }
1722
+ void openPaneTemplateDirect(template, options?.createOptions);
1723
+ };
1724
+
1725
+ return {
1726
+ id: `pane-template:${template.id}:${arg || ""}`,
1727
+ label: template.label,
1728
+ detail: shortcutLabel ? `${template.description} · ${shortcutLabel}` : template.description,
1729
+ category: options?.category ?? (pluginName ? `${pluginName} Panes` : "New Panes"),
1730
+ kind: "action",
1731
+ right: options?.showShortcut ? (template.shortcut?.prefix || placementLabel) : placementLabel,
1732
+ searchText: `${template.paneId} ${template.keywords?.join(" ") || ""} ${shortcutLabel || ""} ${pluginName || ""}`,
1733
+ action,
1734
+ };
1735
+ }, [openPaneTemplateDirect, openPaneTemplateWorkflow, pluginRegistry, runPaneTemplateShortcut, shouldOpenTemplateConfig]);
1736
+
1737
+ const paneTemplateItems = useCallback((filterQuery?: string): ResultItem[] => {
1738
+ const items = getAvailablePaneTemplates().map((template) => createPaneTemplateItem(template));
1739
+ return filterQuery
1740
+ ? fuzzyFilter(items, filterQuery, (item) => `${item.label} ${item.detail} ${item.searchText || ""} ${item.right || ""}`)
1741
+ : items;
1742
+ }, [createPaneTemplateItem, getAvailablePaneTemplates]);
1743
+
1744
+ const paneShortcutItems = useCallback((options?: {
1745
+ filterQuery?: string;
1746
+ createOptions?: PaneTemplateCreateOptions;
1747
+ }): ResultItem[] => {
1748
+ const items = getAvailablePaneTemplates(options?.createOptions)
1749
+ .filter((template) => template.shortcut)
1750
+ .map((template) => createPaneTemplateItem(template, {
1751
+ category: "Panes",
1752
+ createOptions: options?.createOptions,
1753
+ showShortcut: true,
1754
+ }));
1755
+
1756
+ return options?.filterQuery
1757
+ ? fuzzyFilter(items, options.filterQuery, (item) => `${item.label} ${item.detail} ${item.searchText || ""} ${item.right || ""}`)
1758
+ : items;
1759
+ }, [createPaneTemplateItem, getAvailablePaneTemplates]);
1760
+
1761
+ const nonShortcutPaneTemplateItems = useCallback((filterQuery?: string): ResultItem[] => {
1762
+ const items = getAvailablePaneTemplates()
1763
+ .filter((template) => !template.shortcut)
1764
+ .map((template) => createPaneTemplateItem(template, { category: "Panes" }));
1765
+
1766
+ return filterQuery
1767
+ ? fuzzyFilter(items, filterQuery, (item) => `${item.label} ${item.detail} ${item.searchText || ""} ${item.right || ""}`)
1768
+ : items;
1769
+ }, [createPaneTemplateItem, getAvailablePaneTemplates]);
1770
+
1771
+ const runPluginCommandDirect = useCallback(async (command: CommandDef) => {
1772
+ try {
1773
+ await command.execute();
1774
+ closeAll({ revertThemePreview: false });
1775
+ } catch (error) {
1776
+ pluginRegistry.showToastFn(
1777
+ error instanceof Error ? error.message : `Could not run ${command.label.toLowerCase()}.`,
1778
+ { type: "error" },
1779
+ );
1780
+ }
1781
+ }, [closeAll, pluginRegistry]);
1782
+
1783
+ const pluginCommandItems = useCallback((): ResultItem[] => {
1784
+ const disabledPlugins = new Set(state.config.disabledPlugins || []);
1785
+ return [...pluginRegistry.commands.values()]
1786
+ .filter((command) => {
1787
+ if (command.hidden?.()) return false;
1788
+ const pluginId = pluginRegistry.getCommandPluginId(command.id);
1789
+ if (pluginId && disabledPlugins.has(pluginId)) return false;
1790
+ return true;
1791
+ })
1792
+ .map((command) => {
1793
+ const pluginId = pluginRegistry.getCommandPluginId(command.id);
1794
+ const pluginName = pluginId ? pluginRegistry.allPlugins.get(pluginId)?.name : null;
1795
+ return {
1796
+ id: command.id,
1797
+ label: command.label,
1798
+ detail: command.description || "",
1799
+ category: pluginName || "Plugin Commands",
1800
+ kind: "command" as const,
1801
+ action: () => {
1802
+ if (command.wizard && command.wizard.length > 0) {
1803
+ openPluginCommandWorkflow(command);
1804
+ return;
1805
+ }
1806
+ const confirm = resolvePluginCommandConfirm(command);
1807
+ if (confirm) {
1808
+ openInlineConfirm({
1809
+ confirmId: `plugin-command:${command.id}`,
1810
+ title: confirm.title,
1811
+ body: confirm.body,
1812
+ confirmLabel: confirm.confirmLabel || command.label,
1813
+ cancelLabel: confirm.cancelLabel || "Back",
1814
+ tone: confirm.tone || "danger",
1815
+ onConfirm: async () => {
1816
+ await command.execute();
1817
+ },
1818
+ });
1819
+ return;
1820
+ }
1821
+ void runPluginCommandDirect(command);
1822
+ },
1823
+ };
1824
+ });
1825
+ }, [
1826
+ openInlineConfirm,
1827
+ openPluginCommandWorkflow,
1828
+ pluginRegistry,
1829
+ resolvePluginCommandConfirm,
1830
+ runPluginCommandDirect,
1831
+ state.config.disabledPlugins,
1832
+ ]);
1833
+
1834
+ const tickerActionItems = useCallback((): ResultItem[] => {
1835
+ const ticker = activeTickerData;
1836
+ const financials = activeFinancials;
1837
+ if (!ticker) return [];
1838
+
1839
+ return [...pluginRegistry.tickerActions.values()]
1840
+ .filter((action) => !action.filter || action.filter(ticker))
1841
+ .map((action) => ({
1842
+ id: `ticker-action:${action.id}`,
1843
+ label: action.label,
1844
+ detail: ticker.metadata.ticker,
1845
+ category: "Actions",
1846
+ kind: "action" as const,
1847
+ action: () => {
1848
+ void action.execute(ticker, financials);
1849
+ closeAll({ revertThemePreview: false });
1850
+ },
1851
+ }));
1852
+ }, [activeFinancials, activeTickerData, closeAll, pluginRegistry.tickerActions]);
1853
+
1854
+ const runDirectCommand = useCallback((command: Command, arg: string) => {
1855
+ switch (command.id) {
1856
+ case "help":
1857
+ closeAll({ revertThemePreview: false });
1858
+ pluginRegistry.showWidget("help");
1859
+ return;
1860
+ case "pane-settings":
1861
+ if (state.focusedPaneId) openPaneSettingsRoute(state.focusedPaneId);
1862
+ return;
1863
+ case "add-broker-account":
1864
+ case "new-portfolio":
1865
+ case "new-watchlist":
1866
+ case "disconnect-broker-account":
1867
+ case "delete-watchlist":
1868
+ case "delete-portfolio":
1869
+ case "reset-all-data":
1870
+ if (command.id === "add-broker-account" || command.id === "new-portfolio" || command.id === "new-watchlist") {
1871
+ openBuiltInWorkflow(command.id);
1872
+ return;
1873
+ }
1874
+ if (command.id === "disconnect-broker-account") {
1875
+ const instances = state.config.brokerInstances.map((instance) => ({
1876
+ id: instance.id,
1877
+ label: instance.label,
1878
+ description: `${instance.brokerType.toUpperCase()} · ${instance.connectionMode || String(instance.config.connectionMode || "configured")}`,
1879
+ }));
1880
+ if (instances.length === 0) return;
1881
+ openPickerRoute({
1882
+ kind: "picker",
1883
+ pickerId: "disconnect-broker",
1884
+ title: "Disconnect Broker Account",
1885
+ query: "",
1886
+ selectedIdx: 0,
1887
+ hoveredIdx: null,
1888
+ options: instances.map((instance) => ({
1889
+ id: instance.id,
1890
+ label: instance.label,
1891
+ detail: instance.description,
1892
+ description: instance.description,
1893
+ })),
1894
+ });
1895
+ return;
1896
+ }
1897
+ if (command.id === "delete-watchlist") {
1898
+ const options = state.config.watchlists.map((watchlist) => ({
1899
+ id: watchlist.id,
1900
+ label: watchlist.name,
1901
+ detail: `Delete watchlist "${watchlist.name}"`,
1902
+ description: `Delete watchlist "${watchlist.name}"`,
1903
+ }));
1904
+ if (options.length === 0) return;
1905
+ openPickerRoute({
1906
+ kind: "picker",
1907
+ pickerId: "delete-watchlist",
1908
+ title: "Delete Watchlist",
1909
+ query: "",
1910
+ selectedIdx: 0,
1911
+ hoveredIdx: null,
1912
+ options,
1913
+ });
1914
+ return;
1915
+ }
1916
+ if (command.id === "delete-portfolio") {
1917
+ const deletable = state.config.portfolios.filter((portfolio) => !portfolio.brokerId && !portfolio.brokerInstanceId);
1918
+ const options = deletable.map((portfolio) => ({
1919
+ id: portfolio.id,
1920
+ label: portfolio.name,
1921
+ detail: `Delete portfolio "${portfolio.name}"`,
1922
+ description: `Delete portfolio "${portfolio.name}"`,
1923
+ }));
1924
+ if (options.length === 0) return;
1925
+ openPickerRoute({
1926
+ kind: "picker",
1927
+ pickerId: "delete-portfolio",
1928
+ title: "Delete Portfolio",
1929
+ query: "",
1930
+ selectedIdx: 0,
1931
+ hoveredIdx: null,
1932
+ options,
1933
+ });
1934
+ return;
1935
+ }
1936
+ openInlineConfirm({
1937
+ confirmId: "reset-all-data",
1938
+ title: "Reset All Data",
1939
+ body: [
1940
+ "This will permanently delete all portfolios, tickers, notes, broker credentials, and settings.",
1941
+ "Gloomberb will quit and show the setup wizard on next launch.",
1942
+ ],
1943
+ confirmLabel: "Reset Everything",
1944
+ cancelLabel: "Back",
1945
+ tone: "danger",
1946
+ onConfirm: async () => {
1947
+ await resetAllData(stateRef.current.config.dataDir);
1948
+ quitApp();
1949
+ },
1950
+ });
1951
+ return;
1952
+ case "export-config": {
1953
+ const exportPath = `${process.env.HOME || "~"}/gloomberb-config-backup.json`;
1954
+ void exportConfig(state.config, exportPath)
1955
+ .then(() => {
1956
+ pluginRegistry.showToastFn(`Config exported to ${exportPath}`, { type: "success" });
1957
+ closeAll({ revertThemePreview: false });
1958
+ })
1959
+ .catch((error) => {
1960
+ pluginRegistry.showToastFn(error instanceof Error ? error.message : "Export failed.", { type: "error" });
1961
+ });
1962
+ return;
1963
+ }
1964
+ case "import-config": {
1965
+ const importPath = `${process.env.HOME || "~"}/gloomberb-config-backup.json`;
1966
+ void importConfig(state.config.dataDir, importPath)
1967
+ .then((imported) => {
1968
+ dispatch({ type: "SET_CONFIG", config: imported });
1969
+ applyTheme(imported.theme);
1970
+ dispatch({ type: "SET_THEME", theme: imported.theme });
1971
+ pluginRegistry.showToastFn(`Imported config from ${importPath}.`, { type: "success" });
1972
+ closeAll({ revertThemePreview: false });
1973
+ })
1974
+ .catch((error) => {
1975
+ pluginRegistry.showToastFn(error instanceof Error ? error.message : "Import failed.", { type: "error" });
1976
+ });
1977
+ return;
1978
+ }
1979
+ case "cycle-chart-renderer": {
1980
+ const current = state.config.chartPreferences.renderer;
1981
+ const index = CHART_RENDERER_PREFERENCES.indexOf(current);
1982
+ const next = CHART_RENDERER_PREFERENCES[(index + 1) % CHART_RENDERER_PREFERENCES.length] ?? "auto";
1983
+ const nextConfig = {
1984
+ ...state.config,
1985
+ chartPreferences: {
1986
+ ...state.config.chartPreferences,
1987
+ renderer: next,
1988
+ },
1989
+ };
1990
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
1991
+ void saveConfig(nextConfig);
1992
+ closeAll({ revertThemePreview: false });
1993
+ return;
1994
+ }
1995
+ case "check-for-updates":
1996
+ void onCheckForUpdates?.();
1997
+ closeAll({ revertThemePreview: false });
1998
+ return;
1999
+ case "search-ticker":
2000
+ void runTickerSearchShortcut(arg);
2001
+ return;
2002
+ case "remove-watchlist":
2003
+ case "remove-portfolio": {
2004
+ void executeCollectionCommand(command.id, arg);
2005
+ return;
2006
+ }
2007
+ case "add-watchlist":
2008
+ case "add-portfolio":
2009
+ void executeCollectionCommand(command.id, arg);
2010
+ return;
2011
+ default:
2012
+ if (isRouteCommandId(command.id)) {
2013
+ const screen = routeCommandIdToScreen(command.id);
2014
+ if (screen) openModeRoute(screen, arg);
2015
+ return;
2016
+ }
2017
+ command.execute(dispatch, { activeTicker: activeTickerSymbol, activeCollectionId });
2018
+ closeAll({ revertThemePreview: false });
2019
+ }
2020
+ }, [
2021
+ activeCollectionId,
2022
+ activeTickerSymbol,
2023
+ closeAll,
2024
+ dispatch,
2025
+ openBuiltInWorkflow,
2026
+ openInlineConfirm,
2027
+ openModeRoute,
2028
+ openPaneSettingsRoute,
2029
+ openPickerRoute,
2030
+ onCheckForUpdates,
2031
+ pluginRegistry,
2032
+ quitApp,
2033
+ runTickerSearchShortcut,
2034
+ executeCollectionCommand,
2035
+ ]);
2036
+
2037
+ const activeMatch = matchPrefix(rootQuery);
2038
+ const rootTickerSearchArg = activeMatch?.command.id === "search-ticker" && activeMatch.arg.length >= 1
2039
+ ? activeMatch.arg
2040
+ : null;
2041
+
2042
+ useEffect(() => {
2043
+ if (!currentRoute) {
2044
+ lastMainBrowseRef.current = {
2045
+ query: rootQuery,
2046
+ selectedIdx: rootSelectedIdx,
2047
+ };
2048
+ }
2049
+ }, [currentRoute, rootQuery, rootSelectedIdx]);
2050
+
2051
+ useEffect(() => {
2052
+ if (currentRoute) return;
2053
+
2054
+ const previousMode = previousRootModeRef.current;
2055
+ if (rootModeInfo.kind === "themes" && previousMode !== "themes") {
2056
+ rootThemeBaseIdRef.current = state.config.theme;
2057
+ } else if (rootModeInfo.kind !== "themes" && previousMode === "themes") {
2058
+ const rootThemeBaseId = rootThemeBaseIdRef.current;
2059
+ if (rootThemeBaseId && state.config.theme !== rootThemeBaseId) {
2060
+ applyTheme(rootThemeBaseId);
2061
+ dispatch({ type: "SET_THEME", theme: rootThemeBaseId });
2062
+ }
2063
+ rootThemeBaseIdRef.current = null;
2064
+ }
2065
+ previousRootModeRef.current = rootModeInfo.kind;
2066
+ }, [currentRoute, dispatch, rootModeInfo.kind, state.config.theme]);
2067
+
2068
+ const tickerSearchRouteQuery = currentRoute?.kind === "mode" && currentRoute.screen === "ticker-search"
2069
+ ? currentRoute.query
2070
+ : null;
2071
+
2072
+ useEffect(() => {
2073
+ if (tickerSearchRouteQuery == null) {
2074
+ setTickerSearchPending(false);
2075
+ setTickerSearchResults([]);
2076
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
2077
+ return;
2078
+ }
2079
+
2080
+ const searchQuery = tickerSearchRouteQuery.trim();
2081
+ if (!searchQuery) {
2082
+ setTickerSearchPending(false);
2083
+ setTickerSearchResults([]);
2084
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
2085
+ return;
2086
+ }
2087
+
2088
+ setTickerSearchPending(true);
2089
+ const localItems = localTickerSearchResultItems(searchQuery, { limit: 6 });
2090
+ const cachedCandidates = readTickerSearchCache(
2091
+ searchQuery,
2092
+ activePortfolio?.brokerId,
2093
+ activePortfolio?.brokerInstanceId,
2094
+ );
2095
+ setTickerSearchResults(cachedCandidates
2096
+ ? buildTickerSearchResultItems(cachedCandidates, searchQuery)
2097
+ : localItems);
2098
+ const requestId = ++searchRequestIdRef.current;
2099
+ const searchDelay = skipTickerSearchDebounceRef.current ? 0 : 200;
2100
+ skipTickerSearchDebounceRef.current = false;
2101
+
2102
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
2103
+ searchTimerRef.current = setTimeout(async () => {
2104
+ try {
2105
+ const combined = await searchTickerCandidates({
2106
+ query: searchQuery,
2107
+ tickers: stateRef.current.tickers,
2108
+ dataProvider,
2109
+ searchContext: {
2110
+ preferBroker: true,
2111
+ brokerId: activePortfolio?.brokerId,
2112
+ brokerInstanceId: activePortfolio?.brokerInstanceId,
2113
+ },
2114
+ });
2115
+ if (requestId !== searchRequestIdRef.current) return;
2116
+ writeTickerSearchCache(
2117
+ searchQuery,
2118
+ combined,
2119
+ activePortfolio?.brokerId,
2120
+ activePortfolio?.brokerInstanceId,
2121
+ );
2122
+ setTickerSearchResults(buildTickerSearchResultItems(combined, searchQuery));
2123
+ } catch {
2124
+ if (requestId !== searchRequestIdRef.current) return;
2125
+ const nextItems: ResultItem[] = [{
2126
+ id: "search-error",
2127
+ label: "Search failed",
2128
+ detail: "Check your connection",
2129
+ category: "Search",
2130
+ kind: "info",
2131
+ action: () => {},
2132
+ }];
2133
+ setTickerSearchResults(nextItems);
2134
+ } finally {
2135
+ if (requestId === searchRequestIdRef.current) {
2136
+ setTickerSearchPending(false);
2137
+ }
2138
+ }
2139
+ }, searchDelay);
2140
+
2141
+ return () => {
2142
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
2143
+ };
2144
+ }, [
2145
+ activePortfolio?.brokerId,
2146
+ activePortfolio?.brokerInstanceId,
2147
+ dataProvider,
2148
+ buildTickerSearchResultItems,
2149
+ localTickerSearchResultItems,
2150
+ mapTickerSearchCandidateToResultItem,
2151
+ readTickerSearchCache,
2152
+ tickerSearchRouteQuery,
2153
+ writeTickerSearchCache,
2154
+ ]);
2155
+
2156
+ const rootResultModel = useMemo(() => {
2157
+ if (currentRoute) {
2158
+ return { items: [] as ResultItem[], initialIdx: 0 };
2159
+ }
2160
+
2161
+ const isWatchlistTab = state.config.watchlists.some((entry) => entry.id === activeCollectionId);
2162
+ const isPortfolioTab = state.config.portfolios.some((entry) => entry.id === activeCollectionId);
2163
+ const tickerData = activeTickerData;
2164
+ const focusedPaneHasSettings = !!state.focusedPaneId && pluginRegistry.hasPaneSettings(state.focusedPaneId);
2165
+
2166
+ const targetWatchlistId = isWatchlistTab
2167
+ ? activeCollectionId
2168
+ : state.config.watchlists[0]?.id ?? null;
2169
+ const targetPortfolioId = isPortfolioTab
2170
+ ? activeCollectionId
2171
+ : state.config.portfolios.find((entry) => !entry.brokerId)?.id ?? null;
2172
+
2173
+ function shouldShow(command: Command): boolean {
2174
+ switch (command.id) {
2175
+ case "add-watchlist":
2176
+ return !!tickerData && !!targetWatchlistId && !tickerData.metadata.watchlists.includes(targetWatchlistId);
2177
+ case "remove-watchlist":
2178
+ return !!tickerData && tickerData.metadata.watchlists.length > 0;
2179
+ case "add-portfolio":
2180
+ return !!tickerData && !!targetPortfolioId && !tickerData.metadata.portfolios.includes(targetPortfolioId);
2181
+ case "remove-portfolio":
2182
+ return !!tickerData && tickerData.metadata.portfolios.some((id) =>
2183
+ state.config.portfolios.some((entry) => entry.id === id && !entry.brokerId));
2184
+ case "disconnect-broker-account":
2185
+ return state.config.brokerInstances.length > 0;
2186
+ case "delete-watchlist":
2187
+ return state.config.watchlists.length > 0;
2188
+ case "delete-portfolio":
2189
+ return state.config.portfolios.some((entry) => !entry.brokerId && !entry.brokerInstanceId);
2190
+ case "pane-settings":
2191
+ return focusedPaneHasSettings;
2192
+ default:
2193
+ return true;
2194
+ }
2195
+ }
2196
+
2197
+ function smartLabel(command: Command): string {
2198
+ switch (command.id) {
2199
+ case "add-watchlist":
2200
+ return activeTickerSymbol ? `Add ${activeTickerSymbol} to Watchlist` : command.label;
2201
+ case "remove-watchlist":
2202
+ return activeTickerSymbol ? `Remove ${activeTickerSymbol} from Watchlist` : command.label;
2203
+ case "add-portfolio":
2204
+ return activeTickerSymbol ? `Add ${activeTickerSymbol} to Portfolio` : command.label;
2205
+ case "remove-portfolio":
2206
+ return activeTickerSymbol ? `Remove ${activeTickerSymbol} from Portfolio` : command.label;
2207
+ default:
2208
+ return command.label;
2209
+ }
2210
+ }
2211
+
2212
+ function smartDetail(command: Command): string {
2213
+ switch (command.id) {
2214
+ case "add-watchlist": {
2215
+ const name = state.config.watchlists.find((entry) => entry.id === targetWatchlistId)?.name;
2216
+ return name ? `in "${name}"` : command.description;
2217
+ }
2218
+ case "remove-watchlist": {
2219
+ const names = tickerData?.metadata.watchlists
2220
+ .map((id) => state.config.watchlists.find((entry) => entry.id === id)?.name)
2221
+ .filter(Boolean);
2222
+ return names?.length ? `from "${names.join(", ")}"` : command.description;
2223
+ }
2224
+ case "add-portfolio": {
2225
+ const name = state.config.portfolios.find((entry) => entry.id === targetPortfolioId)?.name;
2226
+ return name ? `in "${name}"` : command.description;
2227
+ }
2228
+ case "remove-portfolio": {
2229
+ const names = tickerData?.metadata.portfolios
2230
+ .map((id) => state.config.portfolios.find((entry) => entry.id === id && !entry.brokerId)?.name)
2231
+ .filter(Boolean);
2232
+ return names?.length ? `from "${names.join(", ")}"` : command.description;
2233
+ }
2234
+ case "check-for-updates":
2235
+ if (state.updateCheckInProgress) return "Checking GitHub releases now";
2236
+ if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
2237
+ if (state.updateNotice) return state.updateNotice;
2238
+ return command.description;
2239
+ default:
2240
+ return command.description;
2241
+ }
2242
+ }
2243
+
2244
+ function commandToItem(command: Command): ResultItem | null {
2245
+ if (!shouldShow(command)) return null;
2246
+ return {
2247
+ id: command.id,
2248
+ label: smartLabel(command),
2249
+ detail: smartDetail(command),
2250
+ category: command.category,
2251
+ kind: "command",
2252
+ right: command.prefix || undefined,
2253
+ disabled: command.id === "check-for-updates" && (state.updateCheckInProgress || !!state.updateProgress),
2254
+ action: () => runDirectCommand(command, ""),
2255
+ };
2256
+ }
2257
+
2258
+ function buildRootShortcutItem(): ResultItem | null {
2259
+ if (rootShortcutIntent.kind === "none") return null;
2260
+
2261
+ if (rootShortcutIntent.source === "pane-template") {
2262
+ return createPaneTemplateItem(rootShortcutIntent.template, {
2263
+ category: "Panes",
2264
+ createOptions: rootShortcutIntent.argText ? { arg: rootShortcutIntent.argText } : undefined,
2265
+ showShortcut: true,
2266
+ shortcutExecution: true,
2267
+ });
2268
+ }
2269
+
2270
+ const { command } = rootShortcutIntent;
2271
+ if (command.id === "search-ticker") {
2272
+ const inferredSymbol = normalizeTickerInput(activeTickerSymbol, undefined);
2273
+ if (!rootShortcutIntent.argText && inferredSymbol) {
2274
+ return {
2275
+ id: "search-ticker:inferred",
2276
+ label: inferredSymbol,
2277
+ detail: `Open ${inferredSymbol}`,
2278
+ category: "Search",
2279
+ kind: "action",
2280
+ right: command.prefix,
2281
+ action: () => { void runTickerSearchShortcut(inferredSymbol); },
2282
+ };
2283
+ }
2284
+ return null;
2285
+ }
2286
+
2287
+ if (isCollectionCommand(command.id)) {
2288
+ const commandId = command.id;
2289
+ const action = getCollectionCommandAction(commandId);
2290
+ const kind = getCollectionCommandKind(commandId);
2291
+ const displayTicker = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2292
+ const displayName = kind === "watchlist" ? "Watchlist" : "Portfolio";
2293
+ const localTicker = displayTicker ? state.tickers.get(displayTicker) ?? null : null;
2294
+ const preferredTargetId = resolvePreferredCollectionTarget(
2295
+ state,
2296
+ kind,
2297
+ activeCollectionId,
2298
+ action,
2299
+ localTicker,
2300
+ );
2301
+ const preferredTargetName = preferredTargetId
2302
+ ? (kind === "watchlist"
2303
+ ? state.config.watchlists.find((entry) => entry.id === preferredTargetId)?.name
2304
+ : state.config.portfolios.find((entry) => entry.id === preferredTargetId)?.name)
2305
+ : null;
2306
+
2307
+ return {
2308
+ id: `shortcut:${command.id}:${displayTicker || ""}`,
2309
+ label: displayTicker
2310
+ ? `${getCollectionCommandVerb(action)} ${displayTicker} ${action === "add" ? "to" : "from"} ${displayName}`
2311
+ : command.label,
2312
+ detail: preferredTargetName
2313
+ ? `${action === "add" ? "Target" : "Current"} "${preferredTargetName}"`
2314
+ : displayTicker
2315
+ ? `Choose a ${displayName.toLowerCase()}`
2316
+ : "Choose a ticker",
2317
+ category: command.category,
2318
+ kind: "command",
2319
+ right: command.prefix,
2320
+ action: () => { void executeCollectionCommand(commandId, rootShortcutIntent.argText || undefined); },
2321
+ };
2322
+ }
2323
+
2324
+ return null;
2325
+ }
2326
+
2327
+ const items: ResultItem[] = [];
2328
+ const match = matchPrefix(rootQuery);
2329
+ let initialIdx = 0;
2330
+ const shortcutItem = buildRootShortcutItem();
2331
+
2332
+ if (rootShortcutIntent.kind !== "none" && rootShortcutIntent.source === "pane-template" && shortcutItem) {
2333
+ items.push(shortcutItem);
2334
+ } else if (match && match.command.id === "plugins") {
2335
+ const disabledPlugins = state.config.disabledPlugins || [];
2336
+ const toggleable = [...pluginRegistry.allPlugins.values()].filter((plugin) => plugin.toggleable);
2337
+ const filtered = match.arg
2338
+ ? toggleable.filter((plugin) => (
2339
+ plugin.name.toLowerCase().includes(match.arg.toLowerCase())
2340
+ || plugin.id.includes(match.arg.toLowerCase())
2341
+ ))
2342
+ : toggleable;
2343
+ for (const plugin of filtered) {
2344
+ const enabled = !disabledPlugins.includes(plugin.id);
2345
+ const toggleAction = () => {
2346
+ dispatch({ type: "TOGGLE_PLUGIN", pluginId: plugin.id });
2347
+ const nextDisabled = enabled
2348
+ ? [...disabledPlugins, plugin.id]
2349
+ : disabledPlugins.filter((entry) => entry !== plugin.id);
2350
+ if (enabled) {
2351
+ for (const paneId of pluginRegistry.getPluginPaneIds(plugin.id)) {
2352
+ pluginRegistry.hideWidget(paneId);
2353
+ }
2354
+ }
2355
+ void saveConfig({ ...state.config, disabledPlugins: nextDisabled });
2356
+ };
2357
+ items.push({
2358
+ id: `plugin:${plugin.id}`,
2359
+ label: plugin.name,
2360
+ detail: plugin.description || "",
2361
+ category: "Plugins",
2362
+ kind: "plugin",
2363
+ checked: enabled,
2364
+ pluginToggle: toggleAction,
2365
+ action: toggleAction,
2366
+ });
2367
+ }
2368
+ } else if (match && match.command.id === "new-pane") {
2369
+ items.push(...paneTemplateItems(match.arg));
2370
+ } else if (match && match.command.id === "layout") {
2371
+ const currentLayout = state.config.layout;
2372
+ const focusedPane = state.focusedPaneId ? findPaneInstance(currentLayout, state.focusedPaneId) : null;
2373
+ const focusedPaneDef = focusedPane ? pluginRegistry.panes.get(focusedPane.paneId) : null;
2374
+ const dockedPaneIds = getDockedPaneIds(currentLayout);
2375
+ const focusedFloating = focusedPane ? currentLayout.floating.find((entry) => entry.instanceId === focusedPane.instanceId) : null;
2376
+ const layoutHistory = state.layoutHistory[state.config.activeLayoutIndex];
2377
+ const layoutSnapshot = JSON.stringify(currentLayout);
2378
+ const canMove = (direction: "left" | "right" | "above" | "below") => (
2379
+ !!focusedPane && JSON.stringify(movePaneRelative(currentLayout, focusedPane.instanceId, direction)) !== layoutSnapshot
2380
+ );
2381
+ const layoutItems: ResultItem[] = [];
2382
+
2383
+ if (focusedPane && focusedPaneDef) {
2384
+ layoutItems.push({
2385
+ id: "layout-toggle-mode",
2386
+ label: focusedFloating ? "Dock Pane" : "Float Pane",
2387
+ detail: focusedFloating ? "Return the focused window to the layout" : "Detach the focused pane into a floating window",
2388
+ category: "Focused Pane",
2389
+ kind: "action",
2390
+ action: () => {
2391
+ const { width, height } = pluginRegistry.getTermSizeFn();
2392
+ const nextLayout = focusedFloating
2393
+ ? dockPane(currentLayout, focusedPane.instanceId)
2394
+ : floatPane(currentLayout, focusedPane.instanceId, width, height, focusedPaneDef);
2395
+ persistLayoutChange(nextLayout);
2396
+ closeAll({ revertThemePreview: false });
2397
+ },
2398
+ });
2399
+
2400
+ const moveActions: Array<{
2401
+ id: string;
2402
+ label: string;
2403
+ direction: "left" | "right" | "above" | "below";
2404
+ available: boolean;
2405
+ unavailableDetail: string;
2406
+ }> = [
2407
+ { id: "layout-move-left", label: "Move Left", direction: "left", available: canMove("left"), unavailableDetail: "No column to the left" },
2408
+ { id: "layout-move-right", label: "Move Right", direction: "right", available: canMove("right"), unavailableDetail: "No column to the right" },
2409
+ { id: "layout-move-up", label: "Move Up", direction: "above", available: canMove("above"), unavailableDetail: "No pane above" },
2410
+ { id: "layout-move-down", label: "Move Down", direction: "below", available: canMove("below"), unavailableDetail: "No pane below" },
2411
+ ];
2412
+
2413
+ moveActions.forEach((entry) => {
2414
+ layoutItems.push({
2415
+ id: entry.id,
2416
+ label: entry.label,
2417
+ detail: entry.available ? "Reposition the focused pane" : entry.unavailableDetail,
2418
+ category: "Focused Pane",
2419
+ kind: entry.available ? "action" : "info",
2420
+ disabled: !entry.available,
2421
+ action: entry.available
2422
+ ? () => {
2423
+ persistLayoutChange(movePaneRelative(currentLayout, focusedPane.instanceId, entry.direction));
2424
+ closeAll({ revertThemePreview: false });
2425
+ }
2426
+ : () => {},
2427
+ });
2428
+ });
2429
+
2430
+ layoutItems.push({
2431
+ id: "layout-swap",
2432
+ label: "Swap With…",
2433
+ detail: dockedPaneIds.length + currentLayout.floating.length > 1
2434
+ ? "Choose another pane to swap positions"
2435
+ : "Need at least two panes",
2436
+ category: "Focused Pane",
2437
+ kind: "action",
2438
+ disabled: dockedPaneIds.length + currentLayout.floating.length <= 1,
2439
+ action: () => {
2440
+ const options = [
2441
+ ...dockedPaneIds,
2442
+ ...currentLayout.floating.map((entry) => entry.instanceId),
2443
+ ]
2444
+ .filter((paneId) => paneId !== focusedPane.instanceId)
2445
+ .map((paneId) => {
2446
+ const instance = findPaneInstance(currentLayout, paneId)!;
2447
+ const isFloating = currentLayout.floating.some((entry) => entry.instanceId === paneId);
2448
+ return {
2449
+ id: paneId,
2450
+ label: instance.title || pluginRegistry.panes.get(instance.paneId)?.name || instance.paneId,
2451
+ detail: isFloating ? "Floating window" : "Docked pane",
2452
+ description: isFloating ? "Floating window" : "Docked pane",
2453
+ };
2454
+ });
2455
+ if (options.length === 0) return;
2456
+ openPickerRoute({
2457
+ kind: "picker",
2458
+ pickerId: "layout-swap",
2459
+ title: "Swap With…",
2460
+ query: "",
2461
+ selectedIdx: 0,
2462
+ hoveredIdx: null,
2463
+ options,
2464
+ payload: { sourcePaneId: focusedPane.instanceId },
2465
+ });
2466
+ },
2467
+ });
2468
+
2469
+ layoutItems.push({
2470
+ id: "layout-duplicate",
2471
+ label: "Duplicate Pane",
2472
+ detail: "Create another instance next to the focused pane",
2473
+ category: "Focused Pane",
2474
+ kind: "action",
2475
+ action: () => {
2476
+ duplicatePane(focusedPane.instanceId);
2477
+ closeAll({ revertThemePreview: false });
2478
+ },
2479
+ });
2480
+
2481
+ layoutItems.push({
2482
+ id: "layout-close-pane",
2483
+ label: "Close Pane",
2484
+ detail: "Remove the focused pane from the layout",
2485
+ category: "Focused Pane",
2486
+ kind: "action",
2487
+ action: () => {
2488
+ openInlineConfirm({
2489
+ confirmId: "layout-close-pane",
2490
+ title: "Close Pane",
2491
+ body: [`Close "${focusedPane.title || focusedPaneDef.name || focusedPane.instanceId}"?`],
2492
+ confirmLabel: "Close Pane",
2493
+ cancelLabel: "Back",
2494
+ tone: "danger",
2495
+ onConfirm: () => {
2496
+ persistLayoutChange(removePane(currentLayout, focusedPane.instanceId));
2497
+ },
2498
+ });
2499
+ },
2500
+ });
2501
+ } else {
2502
+ layoutItems.push({
2503
+ id: "layout-no-focused-pane",
2504
+ label: "No focused pane",
2505
+ detail: "Focus a pane to show pane-specific layout actions",
2506
+ category: "Focused Pane",
2507
+ kind: "info",
2508
+ action: () => {},
2509
+ });
2510
+ }
2511
+
2512
+ layoutItems.push({
2513
+ id: "layout-undo",
2514
+ label: "Undo Layout Change",
2515
+ detail: (layoutHistory?.past.length ?? 0) > 0 ? "Restore the previous layout state" : "No previous layout state",
2516
+ category: "Current Layout",
2517
+ kind: "action",
2518
+ disabled: (layoutHistory?.past.length ?? 0) === 0,
2519
+ action: () => {
2520
+ if ((layoutHistory?.past.length ?? 0) === 0) return;
2521
+ dispatch({ type: "UNDO_LAYOUT" });
2522
+ closeAll({ revertThemePreview: false });
2523
+ },
2524
+ });
2525
+ layoutItems.push({
2526
+ id: "layout-redo",
2527
+ label: "Redo Layout Change",
2528
+ detail: (layoutHistory?.future.length ?? 0) > 0 ? "Reapply the next layout state" : "No later layout state",
2529
+ category: "Current Layout",
2530
+ kind: "action",
2531
+ disabled: (layoutHistory?.future.length ?? 0) === 0,
2532
+ action: () => {
2533
+ if ((layoutHistory?.future.length ?? 0) === 0) return;
2534
+ dispatch({ type: "REDO_LAYOUT" });
2535
+ closeAll({ revertThemePreview: false });
2536
+ },
2537
+ });
2538
+ layoutItems.push({
2539
+ id: "layout-reset",
2540
+ label: "Reset Current Layout",
2541
+ detail: "Restore the default two-pane layout",
2542
+ category: "Current Layout",
2543
+ kind: "action",
2544
+ action: () => {
2545
+ openInlineConfirm({
2546
+ confirmId: "layout-reset",
2547
+ title: "Reset Current Layout",
2548
+ body: ["Reset the current layout to the default two-pane arrangement?"],
2549
+ confirmLabel: "Reset Layout",
2550
+ cancelLabel: "Back",
2551
+ tone: "danger",
2552
+ onConfirm: () => {
2553
+ persistLayoutChange(cloneLayout(DEFAULT_LAYOUT));
2554
+ },
2555
+ });
2556
+ },
2557
+ });
2558
+ layoutItems.push({
2559
+ id: "layout-gridlock",
2560
+ label: "Gridlock All Windows",
2561
+ detail: currentLayout.floating.length > 0
2562
+ ? "Infer a tiled layout from the current window positions"
2563
+ : "Retile all panes from their current arrangement",
2564
+ category: "Current Layout",
2565
+ kind: "action",
2566
+ action: () => {
2567
+ const { width, height } = pluginRegistry.getTermSizeFn();
2568
+ persistLayoutChange(gridlockAllPanes(currentLayout, { x: 0, y: 0, width, height }));
2569
+ closeAll({ revertThemePreview: false });
2570
+ },
2571
+ });
2572
+ layoutItems.push({
2573
+ id: "layout-rename",
2574
+ label: "Rename Layout",
2575
+ detail: "Change the current saved layout name",
2576
+ category: "Current Layout",
2577
+ kind: "action",
2578
+ action: () => openBuiltInWorkflow("rename-layout"),
2579
+ });
2580
+ layoutItems.push({
2581
+ id: "layout-duplicate-layout",
2582
+ label: "Duplicate Layout",
2583
+ detail: "Create a copy of the current layout",
2584
+ category: "Current Layout",
2585
+ kind: "action",
2586
+ action: () => {
2587
+ dispatch({ type: "DUPLICATE_LAYOUT", index: state.config.activeLayoutIndex });
2588
+ closeAll({ revertThemePreview: false });
2589
+ },
2590
+ });
2591
+ layoutItems.push({
2592
+ id: "layout-new",
2593
+ label: "New Layout",
2594
+ detail: "Create a fresh saved layout",
2595
+ category: "Current Layout",
2596
+ kind: "action",
2597
+ action: () => openBuiltInWorkflow("new-layout"),
2598
+ });
2599
+
2600
+ state.config.layouts.forEach((savedLayout, index) => {
2601
+ layoutItems.push({
2602
+ id: `layout-switch:${index}`,
2603
+ label: savedLayout.name,
2604
+ detail: index === state.config.activeLayoutIndex ? "Current layout" : "Switch to this saved layout",
2605
+ right: getLayoutPreview(savedLayout.layout),
2606
+ category: "Saved Layouts",
2607
+ kind: "action",
2608
+ current: index === state.config.activeLayoutIndex,
2609
+ action: () => {
2610
+ dispatch({ type: "SWITCH_LAYOUT", index });
2611
+ closeAll({ revertThemePreview: false });
2612
+ },
2613
+ });
2614
+ });
2615
+
2616
+ items.push(...(match.arg
2617
+ ? fuzzyFilter(layoutItems, match.arg, (item) => `${item.label} ${item.detail} ${item.right || ""}`)
2618
+ : layoutItems));
2619
+ } else if (match && match.command.id === "theme") {
2620
+ const savedThemeId = rootThemeBaseIdRef.current ?? state.config.theme;
2621
+ const themeOptions = getThemeOptions();
2622
+ const filtered = match.arg
2623
+ ? themeOptions.filter((theme) => (
2624
+ theme.name.toLowerCase().includes(match.arg.toLowerCase())
2625
+ || theme.id.includes(match.arg.toLowerCase())
2626
+ ))
2627
+ : themeOptions;
2628
+ filtered.forEach((theme, index) => {
2629
+ const isSaved = theme.id === savedThemeId;
2630
+ if (isSaved) initialIdx = index;
2631
+ items.push({
2632
+ id: `theme:${theme.id}`,
2633
+ label: theme.name,
2634
+ detail: theme.description,
2635
+ category: "Themes",
2636
+ kind: "theme",
2637
+ current: isSaved,
2638
+ themeId: theme.id,
2639
+ action: () => {
2640
+ rootThemeBaseIdRef.current = theme.id;
2641
+ const nextConfig = {
2642
+ ...state.config,
2643
+ theme: theme.id,
2644
+ };
2645
+ dispatch({ type: "SET_THEME", theme: theme.id });
2646
+ void saveConfig(nextConfig);
2647
+ closeAll({ revertThemePreview: false });
2648
+ },
2649
+ });
2650
+ });
2651
+ } else if (match && match.command.id === "search-ticker") {
2652
+ if (shortcutItem) {
2653
+ items.push(shortcutItem);
2654
+ }
2655
+ if (!match.arg && !shortcutItem) {
2656
+ items.push({
2657
+ id: "search-hint",
2658
+ label: "Type a ticker symbol",
2659
+ detail: "Search Yahoo Finance and connected brokers",
2660
+ category: "Search",
2661
+ kind: "info",
2662
+ action: () => {},
2663
+ });
2664
+ } else if (match.arg) {
2665
+ items.push(...localTickerSearchResultItems(match.arg, { limit: 6 }));
2666
+ }
2667
+ } else if (match && isCollectionCommand(match.command.id)) {
2668
+ if (shortcutItem) items.push(shortcutItem);
2669
+ } else if (match && !match.command.hasArg) {
2670
+ const item = commandToItem(match.command);
2671
+ if (item) items.push(item);
2672
+ } else if (!rootQuery) {
2673
+ const maxDefaultTickers = 5;
2674
+ const recentSymbols = state.recentTickers.slice(0, maxDefaultTickers);
2675
+ const recentTickers = recentSymbols
2676
+ .map((symbol) => state.tickers.get(symbol))
2677
+ .filter((ticker): ticker is NonNullable<typeof ticker> => ticker != null);
2678
+ if (recentTickers.length < maxDefaultTickers) {
2679
+ const seen = new Set(recentSymbols);
2680
+ for (const ticker of state.tickers.values()) {
2681
+ if (recentTickers.length >= maxDefaultTickers) break;
2682
+ if (!seen.has(ticker.metadata.ticker)) recentTickers.push(ticker);
2683
+ }
2684
+ }
2685
+ items.push(...recentTickers.map((ticker) => ({
2686
+ ...mapTickerSearchCandidateToResultItem(createLocalTickerSearchCandidates([ticker])[0]!),
2687
+ category: "Tickers",
2688
+ })));
2689
+ items.push(...paneShortcutItems());
2690
+ for (const command of commands) {
2691
+ const item = commandToItem(command);
2692
+ if (item) items.push(item);
2693
+ }
2694
+ items.push(...tickerActionItems());
2695
+ items.push(...pluginCommandItems());
2696
+ } else {
2697
+ const tickerItems = localTickerSearchResultItems(undefined, { category: "Tickers" });
2698
+ const commandItems = commands
2699
+ .map((command) => commandToItem(command))
2700
+ .filter((item): item is ResultItem => item !== null);
2701
+ const allItems = [
2702
+ ...tickerItems,
2703
+ ...commandItems,
2704
+ ...paneShortcutItems(),
2705
+ ...nonShortcutPaneTemplateItems(),
2706
+ ...tickerActionItems(),
2707
+ ...pluginCommandItems(),
2708
+ ];
2709
+ items.push(...fuzzyFilter(allItems, rootQuery, (item) => `${item.label} ${item.detail} ${item.searchText || ""} ${item.right || ""}`));
2710
+ }
2711
+
2712
+ return { items, initialIdx };
2713
+ }, [
2714
+ activeCollectionId,
2715
+ activeTickerData,
2716
+ activeTickerSymbol,
2717
+ closeAll,
2718
+ currentRoute,
2719
+ dispatch,
2720
+ duplicatePane,
2721
+ executeCollectionCommand,
2722
+ localTickerSearchResultItems,
2723
+ nonShortcutPaneTemplateItems,
2724
+ openBuiltInWorkflow,
2725
+ openPickerRoute,
2726
+ paneShortcutItems,
2727
+ paneTemplateItems,
2728
+ persistLayoutChange,
2729
+ pluginCommandItems,
2730
+ pluginRegistry,
2731
+ rootModeInfo.kind,
2732
+ rootQuery,
2733
+ rootShortcutIntent,
2734
+ runDirectCommand,
2735
+ runTickerSearchShortcut,
2736
+ state,
2737
+ tickerActionItems,
2738
+ ]);
2739
+
2740
+ useEffect(() => {
2741
+ if (currentRoute) return;
2742
+
2743
+ setRootHoveredIdx((current) => (current != null && current < rootResultModel.items.length ? current : null));
2744
+ const selectionContextChanged =
2745
+ previousRootSelectionContextRef.current?.query !== rootQuery
2746
+ || previousRootSelectionContextRef.current?.mode !== rootModeInfo.kind;
2747
+ if (activeMatch?.command.id === "plugins" || !selectionContextChanged) {
2748
+ setRootSelectedIdx((current) => Math.max(0, Math.min(current, rootResultModel.items.length - 1)));
2749
+ } else {
2750
+ setRootSelectedIdx(Math.max(0, Math.min(rootResultModel.initialIdx, rootResultModel.items.length - 1)));
2751
+ }
2752
+ previousRootSelectionContextRef.current = { query: rootQuery, mode: rootModeInfo.kind };
2753
+ }, [
2754
+ activeMatch?.command.id,
2755
+ currentRoute,
2756
+ rootModeInfo.kind,
2757
+ rootQuery,
2758
+ rootResultModel.initialIdx,
2759
+ rootResultModel.items.length,
2760
+ ]);
2761
+
2762
+ useEffect(() => {
2763
+ if (currentRoute) {
2764
+ setRootSearching(false);
2765
+ setRootProviderResults(null);
2766
+ setRootProviderResultsQuery(null);
2767
+ rootLastSearchedQueryRef.current = null;
2768
+ if (rootSearchTimerRef.current) clearTimeout(rootSearchTimerRef.current);
2769
+ return;
2770
+ }
2771
+
2772
+ if (!rootTickerSearchArg) {
2773
+ setRootSearching(false);
2774
+ setRootProviderResults(null);
2775
+ setRootProviderResultsQuery(null);
2776
+ rootLastSearchedQueryRef.current = null;
2777
+ if (rootSearchTimerRef.current) clearTimeout(rootSearchTimerRef.current);
2778
+ return;
2779
+ }
2780
+
2781
+ const searchQuery = rootTickerSearchArg;
2782
+ if (rootSearchTimerRef.current) clearTimeout(rootSearchTimerRef.current);
2783
+ if (rootLastSearchedQueryRef.current === searchQuery) {
2784
+ return;
2785
+ }
2786
+
2787
+ rootLastSearchedQueryRef.current = searchQuery;
2788
+ setRootSearching(true);
2789
+ const activeSearchPortfolio = state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId);
2790
+ const cachedCandidates = readTickerSearchCache(
2791
+ searchQuery,
2792
+ activeSearchPortfolio?.brokerId,
2793
+ activeSearchPortfolio?.brokerInstanceId,
2794
+ );
2795
+ setRootProviderResults(cachedCandidates ? buildTickerSearchResultItems(cachedCandidates, searchQuery) : null);
2796
+ setRootProviderResultsQuery(cachedCandidates ? searchQuery : null);
2797
+
2798
+ const requestId = ++rootSearchRequestIdRef.current;
2799
+ rootSearchTimerRef.current = setTimeout(async () => {
2800
+ try {
2801
+ const combined = await searchTickerCandidates({
2802
+ query: searchQuery,
2803
+ tickers: state.tickers,
2804
+ dataProvider,
2805
+ searchContext: {
2806
+ preferBroker: true,
2807
+ brokerId: activeSearchPortfolio?.brokerId,
2808
+ brokerInstanceId: activeSearchPortfolio?.brokerInstanceId,
2809
+ },
2810
+ });
2811
+ if (requestId !== rootSearchRequestIdRef.current) return;
2812
+ writeTickerSearchCache(
2813
+ searchQuery,
2814
+ combined,
2815
+ activeSearchPortfolio?.brokerId,
2816
+ activeSearchPortfolio?.brokerInstanceId,
2817
+ );
2818
+ setRootProviderResults(buildTickerSearchResultItems(combined, searchQuery));
2819
+ setRootProviderResultsQuery(searchQuery);
2820
+ } catch {
2821
+ if (requestId !== rootSearchRequestIdRef.current) return;
2822
+ setRootProviderResults([{
2823
+ id: "search-error",
2824
+ label: "Search failed",
2825
+ detail: "Check your connection",
2826
+ category: "Search",
2827
+ kind: "info",
2828
+ action: () => {},
2829
+ }]);
2830
+ setRootProviderResultsQuery(searchQuery);
2831
+ } finally {
2832
+ if (requestId === rootSearchRequestIdRef.current) {
2833
+ setRootSearching(false);
2834
+ }
2835
+ }
2836
+ }, 200);
2837
+
2838
+ return () => {
2839
+ if (rootSearchTimerRef.current) clearTimeout(rootSearchTimerRef.current);
2840
+ };
2841
+ }, [
2842
+ activeCollectionId,
2843
+ buildTickerSearchResultItems,
2844
+ currentRoute,
2845
+ dataProvider,
2846
+ readTickerSearchCache,
2847
+ rootTickerSearchArg,
2848
+ state.config.portfolios,
2849
+ state.tickers,
2850
+ writeTickerSearchCache,
2851
+ ]);
2852
+
2853
+ const rootResults = useMemo(() => {
2854
+ if (rootTickerSearchArg && rootProviderResultsQuery === rootTickerSearchArg && rootProviderResults) {
2855
+ return rootProviderResults;
2856
+ }
2857
+ return rootResultModel.items;
2858
+ }, [rootProviderResults, rootProviderResultsQuery, rootResultModel.items, rootTickerSearchArg]);
2859
+
2860
+ const rootGhostCompletion = !currentRoute && rootShortcutIntent.kind === "inferred-complete"
2861
+ ? rootShortcutIntent.completionQuery
2862
+ : null;
2863
+ const rootGhostSuffix = rootGhostCompletion && rootGhostCompletion.startsWith(rootQuery)
2864
+ ? rootGhostCompletion.slice(rootQuery.length)
2865
+ : null;
2866
+ const rootShortcutFeedback = useMemo(() => {
2867
+ if (currentRoute || rootShortcutIntent.kind === "none") return null;
2868
+
2869
+ if (rootShortcutIntent.source === "pane-template") {
2870
+ const argKind = rootShortcutIntent.template.shortcut?.argKind ?? rootShortcutIntent.template.shortcut?.argPlaceholder;
2871
+ if (argKind === "ticker") {
2872
+ const symbol = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2873
+ if (symbol) {
2874
+ return rootShortcutIntent.kind === "inferred-complete"
2875
+ ? `Shortcut: ${rootShortcutIntent.template.label} for ${symbol} · Tab to accept`
2876
+ : `Shortcut: ${rootShortcutIntent.template.label} for ${symbol}`;
2877
+ }
2878
+ return `Shortcut: ${rootShortcutIntent.template.label} · Enter to choose ticker`;
2879
+ }
2880
+ if (argKind === "ticker-list") {
2881
+ if (rootShortcutIntent.argText) {
2882
+ return `Shortcut: ${rootShortcutIntent.template.label} · ${rootShortcutIntent.argText}`;
2883
+ }
2884
+ const inferred = normalizeTickerInput(activeTickerSymbol, undefined);
2885
+ if (inferred) {
2886
+ return `Shortcut: ${rootShortcutIntent.template.label} · inferred ${inferred} · Tab to accept`;
2887
+ }
2888
+ return `Shortcut: ${rootShortcutIntent.template.label} · Enter tickers to compare`;
2889
+ }
2890
+ return `Shortcut: ${rootShortcutIntent.template.label}`;
2891
+ }
2892
+
2893
+ if (rootShortcutIntent.command.id === "search-ticker") {
2894
+ const symbol = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2895
+ if (symbol) {
2896
+ return rootShortcutIntent.kind === "inferred-complete"
2897
+ ? `Shortcut: Open ${symbol} · Tab to accept`
2898
+ : `Shortcut: Open ${symbol}`;
2899
+ }
2900
+ return "Shortcut: Search ticker";
2901
+ }
2902
+
2903
+ if (isCollectionCommand(rootShortcutIntent.command.id)) {
2904
+ const commandId = rootShortcutIntent.command.id;
2905
+ const action = getCollectionCommandAction(commandId);
2906
+ const kind = getCollectionCommandKind(commandId);
2907
+ const displayTicker = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2908
+ const localTicker = displayTicker ? state.tickers.get(displayTicker) ?? null : null;
2909
+ const preferredTargetId = resolvePreferredCollectionTarget(
2910
+ state,
2911
+ kind,
2912
+ activeCollectionId,
2913
+ action,
2914
+ localTicker,
2915
+ );
2916
+ const preferredTargetName = preferredTargetId
2917
+ ? (kind === "watchlist"
2918
+ ? state.config.watchlists.find((entry) => entry.id === preferredTargetId)?.name
2919
+ : state.config.portfolios.find((entry) => entry.id === preferredTargetId)?.name)
2920
+ : null;
2921
+ if (displayTicker) {
2922
+ return preferredTargetName
2923
+ ? `Shortcut: ${getCollectionCommandVerb(action)} ${displayTicker} ${action === "add" ? "to" : "from"} "${preferredTargetName}"`
2924
+ : `Shortcut: ${getCollectionCommandVerb(action)} ${displayTicker} · choose ${kind}`;
2925
+ }
2926
+ return `Shortcut: ${rootShortcutIntent.command.label}`;
2927
+ }
2928
+
2929
+ return null;
2930
+ }, [activeCollectionId, activeTickerSymbol, currentRoute, rootShortcutIntent, state]);
2931
+
2932
+ const acceptRootShortcutTab = useCallback((): boolean => {
2933
+ const intent = parseRootShortcutIntent({
2934
+ query: rootQueryRef.current,
2935
+ commands,
2936
+ paneTemplates: getAvailablePaneShortcutTemplates(rootQueryRef.current),
2937
+ activeTicker: activeTickerSymbol,
2938
+ });
2939
+ if (intent.kind === "none") return false;
2940
+ if (intent.kind === "inferred-complete" && intent.completionQuery) {
2941
+ setRootQuery(intent.completionQuery);
2942
+ return true;
2943
+ }
2944
+ if (intent.source === "pane-template") {
2945
+ const argKind = intent.template.shortcut?.argKind ?? intent.template.shortcut?.argPlaceholder;
2946
+ if (argKind === "ticker") {
2947
+ openModeRoute("ticker-search", intent.argText, {
2948
+ action: "pane-template",
2949
+ templateId: intent.template.id,
2950
+ });
2951
+ return true;
2952
+ }
2953
+ if (argKind === "ticker-list" || intent.kind === "partial" || intent.kind === "ambiguous") {
2954
+ openPaneTemplateWorkflow(intent.template, { arg: intent.argText || undefined });
2955
+ return true;
2956
+ }
2957
+ return false;
2958
+ }
2959
+
2960
+ if (intent.command.id === "search-ticker") {
2961
+ openModeRoute("ticker-search", intent.argText);
2962
+ return true;
2963
+ }
2964
+ if (isCollectionCommand(intent.command.id)) {
2965
+ openModeRoute("ticker-search", intent.argText, {
2966
+ action: "collection-command",
2967
+ commandId: intent.command.id,
2968
+ });
2969
+ return true;
2970
+ }
2971
+ return false;
2972
+ }, [
2973
+ activeTickerSymbol,
2974
+ getAvailablePaneShortcutTemplates,
2975
+ openModeRoute,
2976
+ openPaneTemplateWorkflow,
2977
+ setRootQuery,
2978
+ ]);
2979
+
2980
+ const resolveImmediateRootSelection = useCallback((query: string): ResultItem | null => {
2981
+ const intent = parseRootShortcutIntent({
2982
+ query,
2983
+ commands,
2984
+ paneTemplates: getAvailablePaneShortcutTemplates(query),
2985
+ activeTicker: activeTickerSymbol,
2986
+ });
2987
+ if (intent.kind !== "none" && intent.source === "pane-template") {
2988
+ return createPaneTemplateItem(intent.template, {
2989
+ category: "Panes",
2990
+ createOptions: intent.argText ? { arg: intent.argText } : undefined,
2991
+ showShortcut: true,
2992
+ shortcutExecution: true,
2993
+ });
2994
+ }
2995
+
2996
+ const match = matchPrefix(query);
2997
+ if (!match) {
2998
+ return null;
2999
+ }
3000
+
3001
+ if (match.command.id === "search-ticker") {
3002
+ if (!match.arg) {
3003
+ const inferredTicker = normalizeTickerInput(activeTickerSymbol, undefined);
3004
+ if (inferredTicker) {
3005
+ return {
3006
+ id: "search-ticker:inferred",
3007
+ label: inferredTicker,
3008
+ detail: `Open ${inferredTicker}`,
3009
+ category: "Search",
3010
+ kind: "action",
3011
+ right: match.command.prefix,
3012
+ action: () => { void runTickerSearchShortcut(inferredTicker); },
3013
+ };
3014
+ }
3015
+ return {
3016
+ id: "search-ticker-route",
3017
+ label: "Search Ticker",
3018
+ detail: "Search Yahoo Finance and connected brokers",
3019
+ category: "Search",
3020
+ kind: "command",
3021
+ action: () => openModeRoute("ticker-search", ""),
3022
+ };
3023
+ }
3024
+ return {
3025
+ id: `search-ticker:${match.arg}`,
3026
+ label: `Open ${match.arg.toUpperCase()}`,
3027
+ detail: "Resolve the symbol exactly or open inline search",
3028
+ category: "Search",
3029
+ kind: "command",
3030
+ right: match.command.prefix,
3031
+ action: () => { void runTickerSearchShortcut(match.arg); },
3032
+ };
3033
+ }
3034
+
3035
+ if (match.command.id === "new-pane") {
3036
+ return paneTemplateItems(match.arg)[0] ?? null;
3037
+ }
3038
+
3039
+ if (match.command.id === "theme") {
3040
+ return {
3041
+ id: "theme-route",
3042
+ label: "Change Theme",
3043
+ detail: "Preview and apply themes",
3044
+ category: "Themes",
3045
+ kind: "command",
3046
+ action: () => openModeRoute("themes", match.arg),
3047
+ };
3048
+ }
3049
+
3050
+ if (match.command.id === "plugins") {
3051
+ return {
3052
+ id: "plugins-route",
3053
+ label: "Manage Plugins",
3054
+ detail: "Toggle optional plugins without leaving the command bar",
3055
+ category: "Plugins",
3056
+ kind: "command",
3057
+ action: () => openModeRoute("plugins", match.arg),
3058
+ };
3059
+ }
3060
+
3061
+ if (match.command.id === "layout") {
3062
+ return {
3063
+ id: "layout-route",
3064
+ label: "Layout Actions",
3065
+ detail: "Organize panes and saved layouts",
3066
+ category: "Layout",
3067
+ kind: "command",
3068
+ action: () => openModeRoute("layout", match.arg),
3069
+ };
3070
+ }
3071
+
3072
+ if (isCollectionCommand(match.command.id)) {
3073
+ const commandId = match.command.id;
3074
+ const displayTicker = normalizeTickerInput(activeTickerSymbol, match.arg);
3075
+ return {
3076
+ id: `shortcut:${commandId}:${displayTicker || ""}`,
3077
+ label: displayTicker
3078
+ ? `${getCollectionCommandVerb(getCollectionCommandAction(commandId))} ${displayTicker}`
3079
+ : match.command.label,
3080
+ detail: displayTicker ? "Resolve the ticker and apply it inline" : "Choose a ticker",
3081
+ category: match.command.category,
3082
+ kind: "command",
3083
+ right: match.command.prefix,
3084
+ action: () => { void executeCollectionCommand(commandId, match.arg || undefined); },
3085
+ };
3086
+ }
3087
+
3088
+ if (!match.command.hasArg) {
3089
+ return {
3090
+ id: `command:${match.command.id}`,
3091
+ label: match.command.label,
3092
+ detail: match.command.description,
3093
+ category: match.command.category,
3094
+ kind: "command",
3095
+ right: match.command.prefix || undefined,
3096
+ action: () => runDirectCommand(match.command, ""),
3097
+ };
3098
+ }
3099
+
3100
+ return null;
3101
+ }, [
3102
+ createPaneTemplateItem,
3103
+ activeTickerSymbol,
3104
+ executeCollectionCommand,
3105
+ getAvailablePaneShortcutTemplates,
3106
+ openModeRoute,
3107
+ paneTemplateItems,
3108
+ runDirectCommand,
3109
+ runTickerSearchShortcut,
3110
+ ]);
3111
+
3112
+ const routeListState = useMemo<ListScreenState | null>(() => {
3113
+ if (!currentRoute) {
3114
+ const emptyState = getEmptyState(
3115
+ rootModeInfo.kind,
3116
+ rootQuery,
3117
+ activeMatch?.command.id === "search-ticker" ? activeMatch.arg : undefined,
3118
+ );
3119
+ return {
3120
+ kind: "root",
3121
+ title: "Commands",
3122
+ query: rootQuery,
3123
+ selectedIdx: rootSelectedIdx,
3124
+ hoveredIdx: rootHoveredIdx,
3125
+ results: orderListResults(rootResults),
3126
+ searching: rootSearching,
3127
+ emptyLabel: emptyState.label,
3128
+ emptyDetail: emptyState.detail,
3129
+ footerLeft: getScreenFooterLeft(null),
3130
+ footerRight: getScreenFooterRight(null),
3131
+ };
3132
+ }
3133
+
3134
+ if (currentRoute.kind === "mode") {
3135
+ switch (currentRoute.screen) {
3136
+ case "themes": {
3137
+ const themeOptions = getThemeOptions();
3138
+ const filtered = currentRoute.query
3139
+ ? themeOptions.filter((theme) => (
3140
+ theme.name.toLowerCase().includes(currentRoute.query.toLowerCase())
3141
+ || theme.id.includes(currentRoute.query.toLowerCase())
3142
+ ))
3143
+ : themeOptions;
3144
+ const results = filtered.map((theme) => ({
3145
+ id: `theme:${theme.id}`,
3146
+ label: theme.name,
3147
+ detail: theme.description,
3148
+ category: "Themes",
3149
+ kind: "theme" as const,
3150
+ current: theme.id === currentRoute.themeBaseId,
3151
+ themeId: theme.id,
3152
+ action: () => {
3153
+ const nextConfig = {
3154
+ ...state.config,
3155
+ theme: theme.id,
3156
+ };
3157
+ dispatch({ type: "SET_THEME", theme: theme.id });
3158
+ void saveConfig(nextConfig);
3159
+ closeAll({ revertThemePreview: false });
3160
+ },
3161
+ }));
3162
+ return {
3163
+ kind: "mode",
3164
+ title: "Change Theme",
3165
+ subtitle: "Preview themes with the keyboard, then save.",
3166
+ query: currentRoute.query,
3167
+ selectedIdx: currentRoute.selectedIdx,
3168
+ hoveredIdx: currentRoute.hoveredIdx,
3169
+ results: orderListResults(results),
3170
+ searching: false,
3171
+ emptyLabel: getEmptyState("themes", currentRoute.query).label,
3172
+ emptyDetail: getEmptyState("themes", currentRoute.query).detail,
3173
+ footerLeft: getScreenFooterLeft(currentRoute),
3174
+ footerRight: getScreenFooterRight(currentRoute),
3175
+ };
3176
+ }
3177
+ case "plugins": {
3178
+ const disabledPlugins = state.config.disabledPlugins || [];
3179
+ const toggleable = [...pluginRegistry.allPlugins.values()].filter((plugin) => plugin.toggleable);
3180
+ const filtered = currentRoute.query
3181
+ ? toggleable.filter((plugin) => (
3182
+ plugin.name.toLowerCase().includes(currentRoute.query.toLowerCase())
3183
+ || plugin.id.includes(currentRoute.query.toLowerCase())
3184
+ ))
3185
+ : toggleable;
3186
+ const results = filtered.map((plugin) => {
3187
+ const enabled = !disabledPlugins.includes(plugin.id);
3188
+ const toggleAction = () => {
3189
+ dispatch({ type: "TOGGLE_PLUGIN", pluginId: plugin.id });
3190
+ const nextDisabled = enabled
3191
+ ? [...disabledPlugins, plugin.id]
3192
+ : disabledPlugins.filter((entry) => entry !== plugin.id);
3193
+ if (enabled) {
3194
+ for (const paneId of pluginRegistry.getPluginPaneIds(plugin.id)) {
3195
+ pluginRegistry.hideWidget(paneId);
3196
+ }
3197
+ }
3198
+ void saveConfig({ ...state.config, disabledPlugins: nextDisabled });
3199
+ };
3200
+ return {
3201
+ id: `plugin:${plugin.id}`,
3202
+ label: plugin.name,
3203
+ detail: plugin.description || "",
3204
+ category: "Plugins",
3205
+ kind: "plugin" as const,
3206
+ checked: enabled,
3207
+ pluginToggle: toggleAction,
3208
+ action: toggleAction,
3209
+ };
3210
+ });
3211
+ return {
3212
+ kind: "mode",
3213
+ title: "Manage Plugins",
3214
+ subtitle: "Toggle optional plugins without leaving the command bar.",
3215
+ query: currentRoute.query,
3216
+ selectedIdx: currentRoute.selectedIdx,
3217
+ hoveredIdx: currentRoute.hoveredIdx,
3218
+ results: orderListResults(results),
3219
+ searching: false,
3220
+ emptyLabel: getEmptyState("plugins", currentRoute.query).label,
3221
+ emptyDetail: getEmptyState("plugins", currentRoute.query).detail,
3222
+ footerLeft: getScreenFooterLeft(currentRoute),
3223
+ footerRight: getScreenFooterRight(currentRoute),
3224
+ };
3225
+ }
3226
+ case "layout": {
3227
+ const currentLayout = state.config.layout;
3228
+ const focusedPane = state.focusedPaneId ? findPaneInstance(currentLayout, state.focusedPaneId) : null;
3229
+ const focusedPaneDef = focusedPane ? pluginRegistry.panes.get(focusedPane.paneId) : null;
3230
+ const dockedPaneIds = getDockedPaneIds(currentLayout);
3231
+ const layoutHistory = state.layoutHistory[state.config.activeLayoutIndex];
3232
+ const layoutSnapshot = JSON.stringify(currentLayout);
3233
+ const canMove = (direction: "left" | "right" | "above" | "below") => (
3234
+ !!focusedPane && JSON.stringify(movePaneRelative(currentLayout, focusedPane.instanceId, direction)) !== layoutSnapshot
3235
+ );
3236
+
3237
+ const layoutItems: ResultItem[] = [];
3238
+ if (focusedPane && focusedPaneDef) {
3239
+ const focusedFloating = currentLayout.floating.find((entry) => entry.instanceId === focusedPane.instanceId);
3240
+ layoutItems.push({
3241
+ id: "layout-toggle-mode",
3242
+ label: focusedFloating ? "Dock Pane" : "Float Pane",
3243
+ detail: focusedFloating ? "Return the focused window to the layout" : "Detach the focused pane into a floating window",
3244
+ category: "Focused Pane",
3245
+ kind: "action",
3246
+ action: () => {
3247
+ const { width, height } = pluginRegistry.getTermSizeFn();
3248
+ const nextLayout = focusedFloating
3249
+ ? dockPane(currentLayout, focusedPane.instanceId)
3250
+ : floatPane(currentLayout, focusedPane.instanceId, width, height, focusedPaneDef);
3251
+ persistLayoutChange(nextLayout);
3252
+ closeAll({ revertThemePreview: false });
3253
+ },
3254
+ });
3255
+
3256
+ const moveActions: Array<{
3257
+ id: string;
3258
+ label: string;
3259
+ direction: "left" | "right" | "above" | "below";
3260
+ available: boolean;
3261
+ unavailableDetail: string;
3262
+ }> = [
3263
+ { id: "layout-move-left", label: "Move Left", direction: "left", available: canMove("left"), unavailableDetail: "No column to the left" },
3264
+ { id: "layout-move-right", label: "Move Right", direction: "right", available: canMove("right"), unavailableDetail: "No column to the right" },
3265
+ { id: "layout-move-up", label: "Move Up", direction: "above", available: canMove("above"), unavailableDetail: "No pane above" },
3266
+ { id: "layout-move-down", label: "Move Down", direction: "below", available: canMove("below"), unavailableDetail: "No pane below" },
3267
+ ];
3268
+
3269
+ moveActions.forEach((entry) => {
3270
+ layoutItems.push({
3271
+ id: entry.id,
3272
+ label: entry.label,
3273
+ detail: entry.available ? "Reposition the focused pane" : entry.unavailableDetail,
3274
+ category: "Focused Pane",
3275
+ kind: entry.available ? "action" : "info",
3276
+ disabled: !entry.available,
3277
+ action: entry.available
3278
+ ? () => {
3279
+ persistLayoutChange(movePaneRelative(currentLayout, focusedPane.instanceId, entry.direction));
3280
+ closeAll({ revertThemePreview: false });
3281
+ }
3282
+ : () => {},
3283
+ });
3284
+ });
3285
+
3286
+ layoutItems.push({
3287
+ id: "layout-swap",
3288
+ label: "Swap With…",
3289
+ detail: dockedPaneIds.length + currentLayout.floating.length > 1
3290
+ ? "Choose another pane to swap positions"
3291
+ : "Need at least two panes",
3292
+ category: "Focused Pane",
3293
+ kind: "action",
3294
+ disabled: dockedPaneIds.length + currentLayout.floating.length <= 1,
3295
+ action: () => {
3296
+ const options = [
3297
+ ...dockedPaneIds,
3298
+ ...currentLayout.floating.map((entry) => entry.instanceId),
3299
+ ]
3300
+ .filter((paneId) => paneId !== focusedPane.instanceId)
3301
+ .map((paneId) => {
3302
+ const instance = findPaneInstance(currentLayout, paneId)!;
3303
+ return {
3304
+ id: paneId,
3305
+ label: instance.title || pluginRegistry.panes.get(instance.paneId)?.name || instance.paneId,
3306
+ detail: currentLayout.floating.some((entry) => entry.instanceId === paneId) ? "Floating window" : "Docked pane",
3307
+ description: currentLayout.floating.some((entry) => entry.instanceId === paneId) ? "Floating window" : "Docked pane",
3308
+ };
3309
+ });
3310
+ if (options.length === 0) return;
3311
+ openPickerRoute({
3312
+ kind: "picker",
3313
+ pickerId: "layout-swap",
3314
+ title: "Swap With…",
3315
+ query: "",
3316
+ selectedIdx: 0,
3317
+ hoveredIdx: null,
3318
+ options,
3319
+ payload: { sourcePaneId: focusedPane.instanceId },
3320
+ });
3321
+ },
3322
+ });
3323
+
3324
+ layoutItems.push({
3325
+ id: "layout-duplicate",
3326
+ label: "Duplicate Pane",
3327
+ detail: "Create another instance next to the focused pane",
3328
+ category: "Focused Pane",
3329
+ kind: "action",
3330
+ action: () => {
3331
+ duplicatePane(focusedPane.instanceId);
3332
+ closeAll({ revertThemePreview: false });
3333
+ },
3334
+ });
3335
+
3336
+ layoutItems.push({
3337
+ id: "layout-close-pane",
3338
+ label: "Close Pane",
3339
+ detail: "Remove the focused pane from the layout",
3340
+ category: "Focused Pane",
3341
+ kind: "action",
3342
+ action: () => {
3343
+ persistLayoutChange(removePane(currentLayout, focusedPane.instanceId));
3344
+ closeAll({ revertThemePreview: false });
3345
+ },
3346
+ });
3347
+ } else {
3348
+ layoutItems.push({
3349
+ id: "layout-no-focused-pane",
3350
+ label: "No focused pane",
3351
+ detail: "Focus a pane to show pane-specific layout actions",
3352
+ category: "Focused Pane",
3353
+ kind: "info",
3354
+ action: () => {},
3355
+ });
3356
+ }
3357
+
3358
+ layoutItems.push({
3359
+ id: "layout-undo",
3360
+ label: "Undo Layout Change",
3361
+ detail: (layoutHistory?.past.length ?? 0) > 0 ? "Restore the previous layout state" : "No previous layout state",
3362
+ category: "Current Layout",
3363
+ kind: "action",
3364
+ disabled: (layoutHistory?.past.length ?? 0) === 0,
3365
+ action: () => {
3366
+ if ((layoutHistory?.past.length ?? 0) === 0) return;
3367
+ dispatch({ type: "UNDO_LAYOUT" });
3368
+ closeAll({ revertThemePreview: false });
3369
+ },
3370
+ });
3371
+ layoutItems.push({
3372
+ id: "layout-redo",
3373
+ label: "Redo Layout Change",
3374
+ detail: (layoutHistory?.future.length ?? 0) > 0 ? "Reapply the next layout state" : "No later layout state",
3375
+ category: "Current Layout",
3376
+ kind: "action",
3377
+ disabled: (layoutHistory?.future.length ?? 0) === 0,
3378
+ action: () => {
3379
+ if ((layoutHistory?.future.length ?? 0) === 0) return;
3380
+ dispatch({ type: "REDO_LAYOUT" });
3381
+ closeAll({ revertThemePreview: false });
3382
+ },
3383
+ });
3384
+ layoutItems.push({
3385
+ id: "layout-reset",
3386
+ label: "Reset Current Layout",
3387
+ detail: "Restore the default two-pane layout",
3388
+ category: "Current Layout",
3389
+ kind: "action",
3390
+ action: () => {
3391
+ persistLayoutChange(cloneLayout(DEFAULT_LAYOUT));
3392
+ closeAll({ revertThemePreview: false });
3393
+ },
3394
+ });
3395
+ layoutItems.push({
3396
+ id: "layout-gridlock",
3397
+ label: "Gridlock All Windows",
3398
+ detail: currentLayout.floating.length > 0
3399
+ ? "Infer a tiled layout from the current window positions"
3400
+ : "Retile all panes from their current arrangement",
3401
+ category: "Current Layout",
3402
+ kind: "action",
3403
+ action: () => {
3404
+ const { width, height } = pluginRegistry.getTermSizeFn();
3405
+ persistLayoutChange(gridlockAllPanes(currentLayout, { x: 0, y: 0, width, height }));
3406
+ closeAll({ revertThemePreview: false });
3407
+ },
3408
+ });
3409
+ layoutItems.push({
3410
+ id: "layout-rename",
3411
+ label: "Rename Layout",
3412
+ detail: "Change the current saved layout name",
3413
+ category: "Current Layout",
3414
+ kind: "action",
3415
+ action: () => openBuiltInWorkflow("rename-layout"),
3416
+ });
3417
+ layoutItems.push({
3418
+ id: "layout-duplicate-layout",
3419
+ label: "Duplicate Layout",
3420
+ detail: "Create a copy of the current layout",
3421
+ category: "Current Layout",
3422
+ kind: "action",
3423
+ action: () => {
3424
+ dispatch({ type: "DUPLICATE_LAYOUT", index: state.config.activeLayoutIndex });
3425
+ closeAll({ revertThemePreview: false });
3426
+ },
3427
+ });
3428
+ layoutItems.push({
3429
+ id: "layout-new",
3430
+ label: "New Layout",
3431
+ detail: "Create a fresh saved layout",
3432
+ category: "Current Layout",
3433
+ kind: "action",
3434
+ action: () => openBuiltInWorkflow("new-layout"),
3435
+ });
3436
+
3437
+ state.config.layouts.forEach((savedLayout, index) => {
3438
+ layoutItems.push({
3439
+ id: `layout-switch:${index}`,
3440
+ label: savedLayout.name,
3441
+ detail: index === state.config.activeLayoutIndex ? "Current layout" : "Switch to this saved layout",
3442
+ right: getLayoutPreview(savedLayout.layout),
3443
+ category: "Saved Layouts",
3444
+ kind: "action",
3445
+ current: index === state.config.activeLayoutIndex,
3446
+ action: () => {
3447
+ dispatch({ type: "SWITCH_LAYOUT", index });
3448
+ closeAll({ revertThemePreview: false });
3449
+ },
3450
+ });
3451
+ });
3452
+
3453
+ const results = currentRoute.query
3454
+ ? fuzzyFilter(layoutItems, currentRoute.query, (item) => `${item.label} ${item.detail} ${item.right || ""}`)
3455
+ : layoutItems;
3456
+
3457
+ return {
3458
+ kind: "mode",
3459
+ title: "Layout Actions",
3460
+ subtitle: "Organize panes and saved layouts.",
3461
+ query: currentRoute.query,
3462
+ selectedIdx: currentRoute.selectedIdx,
3463
+ hoveredIdx: currentRoute.hoveredIdx,
3464
+ results: orderListResults(results),
3465
+ searching: false,
3466
+ emptyLabel: getEmptyState("layout", currentRoute.query).label,
3467
+ emptyDetail: getEmptyState("layout", currentRoute.query).detail,
3468
+ footerLeft: getScreenFooterLeft(currentRoute),
3469
+ footerRight: getScreenFooterRight(currentRoute),
3470
+ };
3471
+ }
3472
+ case "new-pane": {
3473
+ const results = paneTemplateItems(currentRoute.query);
3474
+ return {
3475
+ kind: "mode",
3476
+ title: "New Pane",
3477
+ subtitle: "Create panes from plugin-defined templates.",
3478
+ query: currentRoute.query,
3479
+ selectedIdx: currentRoute.selectedIdx,
3480
+ hoveredIdx: currentRoute.hoveredIdx,
3481
+ results: orderListResults(results),
3482
+ searching: false,
3483
+ emptyLabel: getEmptyState("new-pane", currentRoute.query).label,
3484
+ emptyDetail: getEmptyState("new-pane", currentRoute.query).detail,
3485
+ footerLeft: getScreenFooterLeft(currentRoute),
3486
+ footerRight: getScreenFooterRight(currentRoute),
3487
+ };
3488
+ }
3489
+ case "ticker-search": {
3490
+ const results = currentRoute.query.trim()
3491
+ ? tickerSearchResults.map((item) => adaptTickerSearchRouteResult(item, currentRoute.payload))
3492
+ : [];
3493
+ const emptyState = getEmptyState("search", currentRoute.query, currentRoute.query);
3494
+ return {
3495
+ kind: "mode",
3496
+ title: "Search Ticker",
3497
+ subtitle: "Search Yahoo Finance and connected brokers.",
3498
+ query: currentRoute.query,
3499
+ selectedIdx: currentRoute.selectedIdx,
3500
+ hoveredIdx: currentRoute.hoveredIdx,
3501
+ results: orderListResults(results),
3502
+ searching: tickerSearchPending,
3503
+ emptyLabel: emptyState.label,
3504
+ emptyDetail: emptyState.detail,
3505
+ footerLeft: getScreenFooterLeft(currentRoute),
3506
+ footerRight: getScreenFooterRight(currentRoute),
3507
+ };
3508
+ }
3509
+ default:
3510
+ return null;
3511
+ }
3512
+ }
3513
+
3514
+ if (currentRoute.kind === "picker") {
3515
+ const filteredOptions = getVisibleMultiSelectPickerOptions(currentRoute);
3516
+ const filtered = filteredOptions.map((option) => ({
3517
+ id: option.id,
3518
+ label: option.label,
3519
+ detail: option.detail || "",
3520
+ category: "Options",
3521
+ kind: "action" as const,
3522
+ disabled: option.disabled,
3523
+ action: () => {},
3524
+ }));
3525
+ const selectedIdx = filtered.length === 0
3526
+ ? 0
3527
+ : Math.max(0, Math.min(currentRoute.selectedIdx, filtered.length - 1));
3528
+ return {
3529
+ kind: "picker",
3530
+ title: currentRoute.title,
3531
+ query: currentRoute.query,
3532
+ selectedIdx,
3533
+ hoveredIdx: currentRoute.hoveredIdx,
3534
+ results: orderListResults(filtered),
3535
+ searching: false,
3536
+ emptyLabel: "No matches",
3537
+ emptyDetail: "Adjust the filter to see more options.",
3538
+ footerLeft: getScreenFooterLeft(currentRoute),
3539
+ footerRight: getScreenFooterRight(currentRoute),
3540
+ };
3541
+ }
3542
+
3543
+ if (currentRoute.kind === "pane-settings") {
3544
+ const descriptor = pluginRegistry.resolvePaneSettings(currentRoute.paneId);
3545
+ if (!descriptor) return null;
3546
+ const items = descriptor.settingsDef.fields.map((field) => {
3547
+ const currentValue = descriptor.context.settings[field.key];
3548
+ return {
3549
+ id: `pane-setting:${field.key}`,
3550
+ label: field.label,
3551
+ detail: summarizePaneSettingValue(field, currentValue),
3552
+ category: descriptor.settingsDef.title || "Pane Settings",
3553
+ kind: "action" as const,
3554
+ right: field.type,
3555
+ action: () => {},
3556
+ };
3557
+ });
3558
+ const filtered = currentRoute.query
3559
+ ? fuzzyFilter(items, currentRoute.query, (item) => `${item.label} ${item.detail} ${item.right || ""}`)
3560
+ : items;
3561
+ return {
3562
+ kind: "pane-settings",
3563
+ title: descriptor.settingsDef.title || "Pane Settings",
3564
+ subtitle: descriptor.pane.title || descriptor.paneDef.name,
3565
+ query: currentRoute.query,
3566
+ selectedIdx: currentRoute.selectedIdx,
3567
+ hoveredIdx: currentRoute.hoveredIdx,
3568
+ results: orderListResults(filtered),
3569
+ searching: false,
3570
+ emptyLabel: "No settings match",
3571
+ emptyDetail: currentRoute.query || "This pane exposes no settings.",
3572
+ footerLeft: getScreenFooterLeft(currentRoute),
3573
+ footerRight: getScreenFooterRight(currentRoute),
3574
+ };
3575
+ }
3576
+
3577
+ return null;
3578
+ }, [
3579
+ adaptTickerSearchRouteResult,
3580
+ activeCollectionId,
3581
+ activeMatch,
3582
+ activeTickerData,
3583
+ activeTickerSymbol,
3584
+ closeAll,
3585
+ createPaneTemplateItem,
3586
+ currentRoute,
3587
+ dispatch,
3588
+ duplicatePane,
3589
+ executeCollectionCommand,
3590
+ getAvailablePaneTemplates,
3591
+ localTickerSearchResultItems,
3592
+ mapTickerSearchCandidateToResultItem,
3593
+ nonShortcutPaneTemplateItems,
3594
+ openBuiltInWorkflow,
3595
+ openInlineConfirm,
3596
+ openPaneSettingsRoute,
3597
+ openPickerRoute,
3598
+ paneShortcutItems,
3599
+ paneTemplateItems,
3600
+ persistLayoutChange,
3601
+ pluginCommandItems,
3602
+ pluginRegistry,
3603
+ rootHoveredIdx,
3604
+ rootModeInfo.kind,
3605
+ rootQuery,
3606
+ rootResults,
3607
+ rootSearching,
3608
+ rootSelectedIdx,
3609
+ rootShortcutIntent,
3610
+ runDirectCommand,
3611
+ shouldOpenTemplateConfig,
3612
+ state,
3613
+ tickerActionItems,
3614
+ tickerSearchPending,
3615
+ tickerSearchResults,
3616
+ ]);
3617
+ visibleListStateRef.current = routeListState;
3618
+
3619
+ useEffect(() => {
3620
+ if (!routeListState) return;
3621
+ const selected = currentRoute?.kind === "mode" && currentRoute.screen === "themes"
3622
+ ? routeListState.results[currentRoute.selectedIdx]
3623
+ : !currentRoute && rootModeInfo.kind === "themes"
3624
+ ? routeListState.results[rootSelectedIdx]
3625
+ : null;
3626
+ if (!selected?.themeId) return;
3627
+ if (selected?.themeId && state.config.theme !== selected.themeId) {
3628
+ applyTheme(selected.themeId);
3629
+ dispatch({ type: "SET_THEME", theme: selected.themeId });
3630
+ }
3631
+ }, [currentRoute, dispatch, rootModeInfo.kind, rootSelectedIdx, routeListState, state.config.theme]);
3632
+
3633
+ useEffect(() => {
3634
+ if (currentRoute?.kind !== "workflow") return;
3635
+ ensureRouteFieldFocus(currentRoute);
3636
+ }, [currentRoute, ensureRouteFieldFocus]);
3637
+
3638
+ useEffect(() => {
3639
+ const listState = routeListState;
3640
+ if (!listState) return;
3641
+ const maxIndex = Math.max(0, listState.results.length - 1);
3642
+ if (listState.selectedIdx <= maxIndex) return;
3643
+
3644
+ if (currentRoute && (currentRoute.kind === "mode" || currentRoute.kind === "picker" || currentRoute.kind === "pane-settings")) {
3645
+ updateTopRoute((route) => {
3646
+ if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
3647
+ return { ...route, selectedIdx: maxIndex, hoveredIdx: route.hoveredIdx != null && route.hoveredIdx > maxIndex ? null : route.hoveredIdx };
3648
+ }
3649
+ return route;
3650
+ });
3651
+ return;
3652
+ }
3653
+ setRootSelectedIdx(maxIndex);
3654
+ }, [currentRoute, routeListState, updateTopRoute]);
3655
+
3656
+ const setActiveListQuery = useCallback((nextQuery: string) => {
3657
+ if (!currentRoute) {
3658
+ setRootQuery(nextQuery);
3659
+ return;
3660
+ }
3661
+
3662
+ if (currentRoute.kind === "mode" || currentRoute.kind === "picker" || currentRoute.kind === "pane-settings") {
3663
+ updateTopRoute((route) => {
3664
+ if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
3665
+ return { ...route, query: nextQuery, selectedIdx: 0, hoveredIdx: null };
3666
+ }
3667
+ return route;
3668
+ });
3669
+ }
3670
+ }, [currentRoute, setRootQuery, updateTopRoute]);
3671
+
3672
+ const moveListSelection = useCallback((delta: number) => {
3673
+ const listState = visibleListStateRef.current;
3674
+ if (!listState || listState.results.length === 0 || delta === 0) return;
3675
+ const maxIndex = listState.results.length - 1;
3676
+ if (!currentRouteRef.current) {
3677
+ setRootSelectedIdx((current) => Math.max(0, Math.min(current + delta, maxIndex)));
3678
+ setRootHoveredIdx(null);
3679
+ return;
3680
+ }
3681
+ setRouteStack((current) => {
3682
+ if (current.length === 0) return current;
3683
+ const next = [...current];
3684
+ const top = next[next.length - 1];
3685
+ if (!top || (top.kind !== "mode" && top.kind !== "picker" && top.kind !== "pane-settings")) {
3686
+ return current;
3687
+ }
3688
+ const nextIndex = Math.max(0, Math.min(top.selectedIdx + delta, maxIndex));
3689
+ next[next.length - 1] = { ...top, selectedIdx: nextIndex, hoveredIdx: null };
3690
+ return next;
3691
+ });
3692
+ }, []);
3693
+
3694
+ const setHoveredIndex = useCallback((index: number | null) => {
3695
+ if (!currentRoute) {
3696
+ setRootHoveredIdx(index);
3697
+ return;
3698
+ }
3699
+ updateTopRoute((route) => {
3700
+ if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
3701
+ return { ...route, hoveredIdx: index };
3702
+ }
3703
+ return route;
3704
+ });
3705
+ }, [currentRoute, updateTopRoute]);
3706
+
3707
+ const handleListScroll = useCallback((event: {
3708
+ stopPropagation: () => void;
3709
+ preventDefault: () => void;
3710
+ scroll?: { direction?: string; delta?: number };
3711
+ }) => {
3712
+ event.stopPropagation();
3713
+ event.preventDefault();
3714
+ const direction = event.scroll?.direction;
3715
+ const delta = Math.max(1, Math.round(event.scroll?.delta ?? 1));
3716
+ setHoveredIndex(null);
3717
+ if (direction === "down" || direction === "right") {
3718
+ moveListSelection(delta);
3719
+ } else if (direction === "up" || direction === "left") {
3720
+ moveListSelection(-delta);
3721
+ }
3722
+ }, [moveListSelection, setHoveredIndex]);
3723
+
3724
+ const updateWorkflowValue = useCallback((fieldId: string, value: CommandBarFieldValue) => {
3725
+ updateTopRoute((route) => {
3726
+ if (route.kind !== "workflow") return route;
3727
+ const nextValues = { ...route.values, [fieldId]: value };
3728
+ const nextActiveFieldId = route.activeFieldId && getVisibleWorkflowFields(route.fields, nextValues).some((field) => field.id === route.activeFieldId)
3729
+ ? route.activeFieldId
3730
+ : getFirstVisibleFieldId(route.fields, nextValues);
3731
+ return {
3732
+ ...route,
3733
+ values: nextValues,
3734
+ activeFieldId: nextActiveFieldId,
3735
+ error: null,
3736
+ };
3737
+ });
3738
+ }, [updateTopRoute]);
3739
+
3740
+ const moveWorkflowFocus = useCallback((delta: number) => {
3741
+ if (currentRoute?.kind !== "workflow") return;
3742
+ syncActiveWorkflowTextarea(currentRoute);
3743
+ const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
3744
+ if (visibleFields.length === 0) return;
3745
+ const currentIndex = Math.max(0, visibleFields.findIndex((field) => field.id === currentRoute.activeFieldId));
3746
+ const nextIndex = Math.max(0, Math.min(currentIndex + delta, visibleFields.length - 1));
3747
+ updateTopRoute((route) => route.kind === "workflow"
3748
+ ? { ...route, activeFieldId: visibleFields[nextIndex]?.id ?? route.activeFieldId }
3749
+ : route);
3750
+ }, [currentRoute, syncActiveWorkflowTextarea, updateTopRoute]);
3751
+
3752
+ const openWorkflowFieldPicker = useCallback((route: CommandBarWorkflowRoute, field: CommandBarWorkflowField) => {
3753
+ syncActiveWorkflowTextarea(route);
3754
+ if (field.type === "toggle") {
3755
+ updateWorkflowValue(field.id, !coerceFieldBoolean(route.values[field.id]));
3756
+ return;
3757
+ }
3758
+ if (field.type === "select") {
3759
+ openPickerRoute({
3760
+ kind: "picker",
3761
+ pickerId: "field-select",
3762
+ title: field.label,
3763
+ query: "",
3764
+ selectedIdx: Math.max(0, field.options.findIndex((option) => option.value === coerceFieldString(route.values[field.id]))),
3765
+ hoveredIdx: null,
3766
+ options: field.options.map((option) => ({
3767
+ id: option.value,
3768
+ label: option.label,
3769
+ detail: option.description,
3770
+ description: option.description,
3771
+ })),
3772
+ payload: {
3773
+ parentKind: "workflow",
3774
+ fieldId: field.id,
3775
+ fieldType: field.type,
3776
+ },
3777
+ });
3778
+ return;
3779
+ }
3780
+ if (field.type === "multi-select" || field.type === "ordered-multi-select") {
3781
+ openPickerRoute({
3782
+ kind: "picker",
3783
+ pickerId: "field-multi-select",
3784
+ title: field.label,
3785
+ query: "",
3786
+ selectedIdx: 0,
3787
+ hoveredIdx: null,
3788
+ options: field.options.map((option) => ({
3789
+ id: option.value,
3790
+ label: option.label,
3791
+ detail: option.description,
3792
+ description: option.description,
3793
+ })),
3794
+ payload: {
3795
+ parentKind: "workflow",
3796
+ fieldId: field.id,
3797
+ fieldType: field.type,
3798
+ selectedValues: coerceFieldValues(route.values[field.id]),
3799
+ },
3800
+ });
3801
+ }
3802
+ }, [openPickerRoute, syncActiveWorkflowTextarea, updateWorkflowValue]);
3803
+
3804
+ const activateListSelection = useCallback((options?: { secondary?: boolean; item?: ResultItem }) => {
3805
+ const listState = visibleListStateRef.current;
3806
+ if (!listState) return;
3807
+ const selected = options?.item
3808
+ ?? (!currentRoute && rootQueryRef.current !== rootQuery
3809
+ ? resolveImmediateRootSelection(rootQueryRef.current)
3810
+ : null)
3811
+ ?? listState.results[listState.selectedIdx];
3812
+ if (!selected || selected.disabled) return;
3813
+
3814
+ if (options?.secondary && selected.secondaryAction) {
3815
+ void selected.secondaryAction();
3816
+ return;
3817
+ }
3818
+
3819
+ if (currentRoute?.kind === "picker") {
3820
+ const option = currentRoute.options.find((entry) => entry.id === selected.id);
3821
+ if (!option || option.disabled) return;
3822
+
3823
+ switch (currentRoute.pickerId) {
3824
+ case "layout-swap": {
3825
+ const sourcePaneId = String(currentRoute.payload?.sourcePaneId ?? "");
3826
+ if (!sourcePaneId) return;
3827
+ persistLayoutChange(swapPanes(state.config.layout, sourcePaneId, option.id));
3828
+ closeAll({ revertThemePreview: false });
3829
+ return;
3830
+ }
3831
+ case "delete-watchlist":
3832
+ openInlineConfirm({
3833
+ confirmId: "delete-watchlist",
3834
+ title: "Delete Watchlist",
3835
+ body: [`Delete "${option.label}"? Tickers will not be deleted.`],
3836
+ confirmLabel: "Delete Watchlist",
3837
+ cancelLabel: "Back",
3838
+ tone: "danger",
3839
+ onConfirm: async () => {
3840
+ await deleteWatchlist(option.id);
3841
+ },
3842
+ });
3843
+ return;
3844
+ case "delete-portfolio":
3845
+ openInlineConfirm({
3846
+ confirmId: "delete-portfolio",
3847
+ title: "Delete Portfolio",
3848
+ body: [`Delete "${option.label}"? Tickers will not be deleted.`],
3849
+ confirmLabel: "Delete Portfolio",
3850
+ cancelLabel: "Back",
3851
+ tone: "danger",
3852
+ onConfirm: async () => {
3853
+ await deletePortfolio(option.id);
3854
+ },
3855
+ });
3856
+ return;
3857
+ case "disconnect-broker":
3858
+ openInlineConfirm({
3859
+ confirmId: "disconnect-broker",
3860
+ title: "Disconnect Broker Account",
3861
+ body: [`Remove "${option.label}" and all imported broker portfolios, positions, and contracts?`],
3862
+ confirmLabel: "Disconnect Broker",
3863
+ cancelLabel: "Back",
3864
+ tone: "danger",
3865
+ onConfirm: async () => {
3866
+ await disconnectBrokerInstance(option.id);
3867
+ },
3868
+ });
3869
+ return;
3870
+ case "collection-target": {
3871
+ const commandId = String(currentRoute.payload?.commandId ?? "");
3872
+ const symbol = String(currentRoute.payload?.symbol ?? "");
3873
+ if (!isCollectionCommand(commandId)) return;
3874
+ void executeCollectionCommand(commandId, symbol, option.id);
3875
+ return;
3876
+ }
3877
+ case "field-select": {
3878
+ const parentKind = String(currentRoute.payload?.parentKind ?? "");
3879
+ if (parentKind === "workflow") {
3880
+ updateWorkflowValue(String(currentRoute.payload?.fieldId ?? ""), option.id);
3881
+ setRouteStack((current) => current.slice(0, -1));
3882
+ return;
3883
+ }
3884
+ if (parentKind === "pane-settings") {
3885
+ const paneId = String(currentRoute.payload?.paneId ?? "");
3886
+ const field = currentRoute.payload?.field as PaneSettingField | undefined;
3887
+ if (!paneId || !field) return;
3888
+ void pluginRegistry.applyPaneSettingValueFn(paneId, field, option.id)
3889
+ .then(() => {
3890
+ setRouteStack((current) => current.slice(0, -1));
3891
+ })
3892
+ .catch((error) => {
3893
+ updateTopRoute((route) => route.kind === "pane-settings"
3894
+ ? { ...route, error: error instanceof Error ? error.message : "Could not apply that setting." }
3895
+ : route);
3896
+ });
3897
+ }
3898
+ return;
3899
+ }
3900
+ default:
3901
+ return;
3902
+ }
3903
+ }
3904
+
3905
+ if (currentRoute?.kind === "pane-settings") {
3906
+ const descriptor = pluginRegistry.resolvePaneSettings(currentRoute.paneId);
3907
+ if (!descriptor) return;
3908
+ const selectedField = descriptor.settingsDef.fields.find((field) => `pane-setting:${field.key}` === selected.id);
3909
+ if (!selectedField) return;
3910
+ const currentValue = descriptor.context.settings[selectedField.key];
3911
+ const normalized = normalizePaneSettingField(currentRoute.paneId, selectedField, currentValue);
3912
+ if (normalized.mode === "toggle") {
3913
+ updateTopRoute((route) => route.kind === "pane-settings"
3914
+ ? { ...route, pendingFieldKey: selectedField.key, error: null }
3915
+ : route);
3916
+ void pluginRegistry.applyPaneSettingValueFn(currentRoute.paneId, selectedField, !normalized.value)
3917
+ .then(() => {
3918
+ updateTopRoute((route) => route.kind === "pane-settings"
3919
+ ? { ...route, pendingFieldKey: null, error: null }
3920
+ : route);
3921
+ })
3922
+ .catch((error) => {
3923
+ updateTopRoute((route) => route.kind === "pane-settings"
3924
+ ? {
3925
+ ...route,
3926
+ pendingFieldKey: null,
3927
+ error: error instanceof Error ? error.message : "Could not apply that setting.",
3928
+ }
3929
+ : route);
3930
+ });
3931
+ return;
3932
+ }
3933
+ if (normalized.mode === "workflow" && normalized.route) {
3934
+ openWorkflowRoute(normalized.route);
3935
+ return;
3936
+ }
3937
+ if (normalized.mode === "picker") {
3938
+ openPickerRoute(normalized.route);
3939
+ }
3940
+ return;
3941
+ }
3942
+
3943
+ void selected.action();
3944
+ }, [
3945
+ closeAll,
3946
+ currentRoute,
3947
+ normalizePaneSettingField,
3948
+ openInlineConfirm,
3949
+ openPickerRoute,
3950
+ openWorkflowRoute,
3951
+ persistLayoutChange,
3952
+ pluginRegistry,
3953
+ resolveImmediateRootSelection,
3954
+ rootQuery,
3955
+ state.config.layout,
3956
+ updateTopRoute,
3957
+ updateWorkflowValue,
3958
+ deletePortfolio,
3959
+ deleteWatchlist,
3960
+ disconnectBrokerInstance,
3961
+ executeCollectionCommand,
3962
+ ]);
3963
+
3964
+ const confirmCurrentRoute = useCallback(async () => {
3965
+ if (currentRoute?.kind !== "confirm") return;
3966
+ updateTopRoute((route) => route.kind === "confirm"
3967
+ ? { ...route, pending: true, error: null }
3968
+ : route);
3969
+ try {
3970
+ await currentRoute.onConfirm();
3971
+ if (currentRoute.successBehavior === "back") {
3972
+ setRouteStack((current) => current.slice(0, -1));
3973
+ } else if (currentRoute.successBehavior !== "stay") {
3974
+ closeAll({ revertThemePreview: false });
3975
+ }
3976
+ } catch (error) {
3977
+ updateTopRoute((route) => route.kind === "confirm"
3978
+ ? {
3979
+ ...route,
3980
+ pending: false,
3981
+ error: error instanceof Error ? error.message : "Could not complete that action.",
3982
+ }
3983
+ : route);
3984
+ }
3985
+ }, [
3986
+ closeAll,
3987
+ currentRoute,
3988
+ updateTopRoute,
3989
+ ]);
3990
+
3991
+ const handleMultiSelectToggle = useCallback((optionId: string) => {
3992
+ if (currentRoute?.kind !== "picker" || currentRoute.pickerId !== "field-multi-select") return;
3993
+ updateTopRoute((route) => {
3994
+ if (route.kind !== "picker" || route.pickerId !== "field-multi-select") return route;
3995
+ const selectedValues = coerceFieldValues(route.payload?.selectedValues as CommandBarFieldValue | undefined);
3996
+ const nextSelectedValues = toggleSelectedValue(selectedValues, optionId);
3997
+ const nextRoute = {
3998
+ ...route,
3999
+ payload: {
4000
+ ...route.payload,
4001
+ selectedValues: nextSelectedValues,
4002
+ },
4003
+ };
4004
+ const nextOptions = getVisibleMultiSelectPickerOptions(nextRoute);
4005
+ const nextSelectedIdx = nextOptions.findIndex((option) => option.id === optionId);
4006
+ return {
4007
+ ...nextRoute,
4008
+ selectedIdx: nextSelectedIdx >= 0 ? nextSelectedIdx : 0,
4009
+ };
4010
+ });
4011
+ }, [currentRoute, updateTopRoute]);
4012
+
4013
+ const handleMultiSelectMove = useCallback((direction: "up" | "down") => {
4014
+ if (currentRoute?.kind !== "picker" || currentRoute.pickerId !== "field-multi-select") return;
4015
+ const visibleOptions = getVisibleMultiSelectPickerOptions(currentRoute);
4016
+ const selectedItem = visibleOptions[currentRoute.selectedIdx];
4017
+ if (!selectedItem) return;
4018
+
4019
+ updateTopRoute((route) => {
4020
+ if (route.kind !== "picker" || route.pickerId !== "field-multi-select") return route;
4021
+ const fieldType = String(route.payload?.fieldType ?? "");
4022
+ if (fieldType !== "ordered-multi-select") return route;
4023
+ const workflowField: CommandBarWorkflowField = {
4024
+ id: "selectedValues",
4025
+ label: route.title,
4026
+ type: "ordered-multi-select",
4027
+ options: route.options.map((option) => ({
4028
+ label: option.label,
4029
+ value: option.id,
4030
+ description: option.description,
4031
+ })),
4032
+ };
4033
+ const selectedValues = coerceFieldValues(route.payload?.selectedValues as CommandBarFieldValue | undefined);
4034
+ const nextSelectedValues = moveSelectedValue(workflowField, selectedValues, selectedItem.id, direction);
4035
+ const nextRoute = {
4036
+ ...route,
4037
+ payload: {
4038
+ ...route.payload,
4039
+ selectedValues: nextSelectedValues,
4040
+ },
4041
+ };
4042
+ const nextOptions = getVisibleMultiSelectPickerOptions(nextRoute);
4043
+ const nextSelectedIdx = nextOptions.findIndex((option) => option.id === selectedItem.id);
4044
+ return {
4045
+ ...nextRoute,
4046
+ selectedIdx: nextSelectedIdx >= 0 ? nextSelectedIdx : route.selectedIdx,
4047
+ };
4048
+ });
4049
+ }, [currentRoute, updateTopRoute]);
4050
+
4051
+ const commitMultiSelectPicker = useCallback(() => {
4052
+ if (currentRoute?.kind !== "picker" || currentRoute.pickerId !== "field-multi-select") return;
4053
+ const selectedValues = coerceFieldValues(currentRoute.payload?.selectedValues as CommandBarFieldValue | undefined);
4054
+ const parentKind = String(currentRoute.payload?.parentKind ?? "");
4055
+ if (parentKind === "workflow") {
4056
+ updateWorkflowValue(String(currentRoute.payload?.fieldId ?? ""), selectedValues);
4057
+ setRouteStack((current) => current.slice(0, -1));
4058
+ return;
4059
+ }
4060
+
4061
+ if (parentKind === "pane-settings") {
4062
+ const paneId = String(currentRoute.payload?.paneId ?? "");
4063
+ const field = currentRoute.payload?.field as PaneSettingField | undefined;
4064
+ if (!paneId || !field) return;
4065
+ void pluginRegistry.applyPaneSettingValueFn(paneId, field, selectedValues)
4066
+ .then(() => {
4067
+ setRouteStack((current) => current.slice(0, -1));
4068
+ })
4069
+ .catch((error) => {
4070
+ updateTopRoute((route) => route.kind === "pane-settings"
4071
+ ? { ...route, error: error instanceof Error ? error.message : "Could not apply that setting." }
4072
+ : route);
4073
+ });
4074
+ }
4075
+ }, [currentRoute, pluginRegistry, updateTopRoute, updateWorkflowValue]);
4076
+
4077
+ useEffect(() => {
4078
+ const keyInput = renderer.keyInput as typeof renderer.keyInput & {
4079
+ onInternal?: (event: "keypress", handler: (event: {
4080
+ name: string;
4081
+ sequence?: string;
4082
+ ctrl?: boolean;
4083
+ meta?: boolean;
4084
+ shift?: boolean;
4085
+ stopPropagation: () => void;
4086
+ preventDefault: () => void;
4087
+ }) => void) => void;
4088
+ offInternal?: (event: "keypress", handler: (event: {
4089
+ name: string;
4090
+ sequence?: string;
4091
+ ctrl?: boolean;
4092
+ meta?: boolean;
4093
+ shift?: boolean;
4094
+ stopPropagation: () => void;
4095
+ preventDefault: () => void;
4096
+ }) => void) => void;
4097
+ };
4098
+ const handleKeyPress = (event: {
4099
+ name: string;
4100
+ sequence?: string;
4101
+ ctrl?: boolean;
4102
+ meta?: boolean;
4103
+ shift?: boolean;
4104
+ stopPropagation: () => void;
4105
+ preventDefault: () => void;
4106
+ }) => {
4107
+ if (event.name === "escape" || event.name === "`") {
4108
+ event.stopPropagation();
4109
+ event.preventDefault();
4110
+ dismissCommandBar();
4111
+ return;
4112
+ }
4113
+
4114
+ if (currentRoute?.kind === "confirm") {
4115
+ if (event.name === "return" || event.name === "enter" || event.name === "y") {
4116
+ event.stopPropagation();
4117
+ event.preventDefault();
4118
+ void confirmCurrentRoute();
4119
+ return;
4120
+ }
4121
+ if (event.name === "n") {
4122
+ event.stopPropagation();
4123
+ event.preventDefault();
4124
+ popRoute();
4125
+ }
4126
+ return;
4127
+ }
4128
+
4129
+ if (currentRoute?.kind === "workflow") {
4130
+ const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
4131
+ const activeField = visibleFields.find((field) => field.id === currentRoute.activeFieldId) ?? visibleFields[0];
4132
+ const activeTextarea = activeField?.type === "textarea";
4133
+
4134
+ if (event.name === "tab") {
4135
+ event.stopPropagation();
4136
+ event.preventDefault();
4137
+ moveWorkflowFocus(event.shift ? -1 : 1);
4138
+ return;
4139
+ }
4140
+
4141
+ if (activeTextarea && event.ctrl && event.name === "s") {
4142
+ event.stopPropagation();
4143
+ event.preventDefault();
4144
+ void submitWorkflowRoute(currentRoute);
4145
+ return;
4146
+ }
4147
+
4148
+ if (activeTextarea && (event.name === "up" || event.name === "down" || (event.ctrl && (event.name === "p" || event.name === "n")))) {
4149
+ return;
4150
+ }
4151
+
4152
+ if (event.name === "up" || (event.ctrl && event.name === "p")) {
4153
+ event.stopPropagation();
4154
+ event.preventDefault();
4155
+ moveWorkflowFocus(-1);
4156
+ return;
4157
+ }
4158
+
4159
+ if (event.name === "down" || (event.ctrl && event.name === "n")) {
4160
+ event.stopPropagation();
4161
+ event.preventDefault();
4162
+ moveWorkflowFocus(1);
4163
+ return;
4164
+ }
4165
+
4166
+ if (event.name === "space" && activeField?.type === "toggle") {
4167
+ event.stopPropagation();
4168
+ event.preventDefault();
4169
+ updateWorkflowValue(activeField.id, !coerceFieldBoolean(currentRoute.values[activeField.id]));
4170
+ return;
4171
+ }
4172
+
4173
+ if (event.name === "return" || event.name === "enter") {
4174
+ if (!activeField) return;
4175
+ if (activeField.type === "select" || activeField.type === "multi-select" || activeField.type === "ordered-multi-select" || activeField.type === "toggle") {
4176
+ event.stopPropagation();
4177
+ event.preventDefault();
4178
+ openWorkflowFieldPicker(currentRoute, activeField);
4179
+ return;
4180
+ }
4181
+ }
4182
+
4183
+ return;
4184
+ }
4185
+
4186
+ if (currentRoute?.kind === "picker") {
4187
+ if (event.name === "up" || (event.ctrl && event.name === "p")) {
4188
+ event.stopPropagation();
4189
+ event.preventDefault();
4190
+ moveListSelection(-1);
4191
+ return;
4192
+ }
4193
+ if (event.name === "down" || (event.ctrl && event.name === "n")) {
4194
+ event.stopPropagation();
4195
+ event.preventDefault();
4196
+ moveListSelection(1);
4197
+ return;
4198
+ }
4199
+ if (currentRoute.pickerId === "field-multi-select" && (event.name === "space" || event.sequence === " ")) {
4200
+ event.stopPropagation();
4201
+ event.preventDefault();
4202
+ const selected = routeListState?.results[currentRoute.selectedIdx];
4203
+ if (!selected) return;
4204
+ handleMultiSelectToggle(selected.id);
4205
+ return;
4206
+ }
4207
+ if (currentRoute.pickerId === "field-multi-select" && event.name === "[") {
4208
+ event.stopPropagation();
4209
+ event.preventDefault();
4210
+ handleMultiSelectMove("up");
4211
+ return;
4212
+ }
4213
+ if (currentRoute.pickerId === "field-multi-select" && event.name === "]") {
4214
+ event.stopPropagation();
4215
+ event.preventDefault();
4216
+ handleMultiSelectMove("down");
4217
+ return;
4218
+ }
4219
+ if (event.name === "return" || event.name === "enter") {
4220
+ event.stopPropagation();
4221
+ event.preventDefault();
4222
+ if (currentRoute.pickerId === "field-multi-select") {
4223
+ commitMultiSelectPicker();
4224
+ return;
4225
+ }
4226
+ activateListSelection();
4227
+ }
4228
+ return;
4229
+ }
4230
+
4231
+ if (currentRoute?.kind === "pane-settings") {
4232
+ if (event.name === "up" || (event.ctrl && event.name === "p")) {
4233
+ event.stopPropagation();
4234
+ event.preventDefault();
4235
+ moveListSelection(-1);
4236
+ return;
4237
+ }
4238
+ if (event.name === "down" || (event.ctrl && event.name === "n")) {
4239
+ event.stopPropagation();
4240
+ event.preventDefault();
4241
+ moveListSelection(1);
4242
+ return;
4243
+ }
4244
+ if (event.name === "return" || event.name === "enter" || event.name === "space") {
4245
+ event.stopPropagation();
4246
+ event.preventDefault();
4247
+ activateListSelection();
4248
+ }
4249
+ return;
4250
+ }
4251
+
4252
+ if (!routeListState) return;
4253
+
4254
+ if (!currentRoute && event.name === "tab") {
4255
+ if (acceptRootShortcutTab()) {
4256
+ event.stopPropagation();
4257
+ event.preventDefault();
4258
+ return;
4259
+ }
4260
+ }
4261
+
4262
+ if (event.name === "down" || (event.ctrl && event.name === "n")) {
4263
+ event.stopPropagation();
4264
+ event.preventDefault();
4265
+ moveListSelection(1);
4266
+ return;
4267
+ }
4268
+
4269
+ if (event.name === "up" || (event.ctrl && event.name === "p")) {
4270
+ event.stopPropagation();
4271
+ event.preventDefault();
4272
+ moveListSelection(-1);
4273
+ return;
4274
+ }
4275
+
4276
+ if ((event.meta && (event.name === "backspace" || event.name === "delete")) || (event.ctrl && event.name === "u")) {
4277
+ event.stopPropagation();
4278
+ event.preventDefault();
4279
+ setActiveListQuery("");
4280
+ return;
4281
+ }
4282
+
4283
+ if ((event.ctrl && event.name === "w") || (event.meta && (event.name === "h" || event.name === "u"))) {
4284
+ event.stopPropagation();
4285
+ event.preventDefault();
4286
+ const trimmed = routeListState.query.replace(/\s+$/, "");
4287
+ const nextQuery = trimmed.replace(/[^\s]+$/, "").replace(/\s+$/, "");
4288
+ setActiveListQuery(nextQuery);
4289
+ return;
4290
+ }
4291
+
4292
+ const pluginToggleMode = (currentRoute?.kind === "mode" && currentRoute.screen === "plugins")
4293
+ || (!currentRoute && rootModeInfo.kind === "plugins");
4294
+ if (pluginToggleMode && event.name === "space") {
4295
+ event.stopPropagation();
4296
+ event.preventDefault();
4297
+ const selected = routeListState.results[routeListState.selectedIdx];
4298
+ if (selected?.pluginToggle) {
4299
+ void selected.pluginToggle();
4300
+ }
4301
+ return;
4302
+ }
4303
+
4304
+ if (event.name === "return" || event.name === "enter") {
4305
+ event.stopPropagation();
4306
+ event.preventDefault();
4307
+ if (event.shift) {
4308
+ activateListSelection({ secondary: true });
4309
+ return;
4310
+ }
4311
+ activateListSelection();
4312
+ }
4313
+ };
4314
+
4315
+ if (keyInput.onInternal) {
4316
+ keyInput.onInternal("keypress", handleKeyPress);
4317
+ } else {
4318
+ renderer.keyInput.on("keypress", handleKeyPress);
4319
+ }
4320
+ return () => {
4321
+ if (keyInput.offInternal) {
4322
+ keyInput.offInternal("keypress", handleKeyPress);
4323
+ } else {
4324
+ renderer.keyInput.off("keypress", handleKeyPress);
4325
+ }
4326
+ };
4327
+ }, [
4328
+ activateListSelection,
4329
+ acceptRootShortcutTab,
4330
+ commitMultiSelectPicker,
4331
+ confirmCurrentRoute,
4332
+ currentRoute,
4333
+ dismissCommandBar,
4334
+ handleMultiSelectMove,
4335
+ handleMultiSelectToggle,
4336
+ moveListSelection,
4337
+ moveWorkflowFocus,
4338
+ openWorkflowFieldPicker,
4339
+ popRoute,
4340
+ renderer,
4341
+ rootModeInfo.kind,
4342
+ routeListState,
4343
+ setActiveListQuery,
4344
+ updateWorkflowValue,
4345
+ ]);
4346
+
4347
+ const barWidth = Math.max(42, Math.min(72, termWidth - 8, Math.floor(termWidth * 0.68)));
4348
+ const bodyHeight = Math.min(16, Math.max(9, termHeight - 9));
4349
+ const barHeight = bodyHeight + 7;
4350
+ const barLeft = Math.max(4, Math.floor((termWidth - barWidth) / 2));
4351
+ const barTop = Math.max(1, Math.floor((termHeight - barHeight) / 2));
4352
+ const contentPadding = 3;
4353
+ const paletteBg = commandBarBg();
4354
+ const paletteHeadingText = commandBarHeadingText();
4355
+ const paletteHoverBg = commandBarHoverBg();
4356
+ const paletteSelectedBg = commandBarSelectedBg();
4357
+ const paletteSelectedText = commandBarSelectedText();
4358
+ const paletteText = commandBarText();
4359
+ const paletteSubtleText = commandBarSubtleText();
4360
+ const resultsInnerWidth = Math.max(12, barWidth - contentPadding * 2);
4361
+ const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
4362
+ const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
4363
+ const queryDisplayWidth = Math.max(8, barWidth - contentPadding * 2);
4364
+ const visibleListState = routeListState && (routeListState.kind === "root" || routeListState.kind === "mode" || routeListState.kind === "picker" || routeListState.kind === "pane-settings")
4365
+ ? routeListState
4366
+ : null;
4367
+ const showCustomMultiSelectPicker = currentRoute?.kind === "picker" && currentRoute.pickerId === "field-multi-select";
4368
+ const bodySlotKey = showCustomMultiSelectPicker
4369
+ ? "picker:field-multi-select"
4370
+ : currentRoute?.kind === "picker"
4371
+ ? `picker:${currentRoute.pickerId}`
4372
+ : currentRoute?.kind ?? "root";
4373
+
4374
+ const renderListBody = () => {
4375
+ if (!visibleListState) return null;
4376
+
4377
+ const allRows: Array<
4378
+ | { kind: "spacer"; id: string }
4379
+ | { kind: "heading"; id: string; label: string }
4380
+ | { kind: "item"; item: ResultItem; globalIdx: number }
4381
+ | { kind: "message"; id: string; label: string; dim?: boolean }
4382
+ | { kind: "spinner"; id: string; label: string }
4383
+ | { kind: "filler"; id: string }
4384
+ > = [];
4385
+ const sections = buildSections(visibleListState.results);
4386
+ let globalIdx = 0;
4387
+ sections.forEach((section, sectionIndex) => {
4388
+ if (sectionIndex > 0) {
4389
+ allRows.push({ kind: "spacer", id: `spacer:${sectionIndex}:${section.category}` });
4390
+ }
4391
+ allRows.push({ kind: "heading", id: `heading:${sectionIndex}:${section.category}`, label: section.category });
4392
+ for (const item of section.items) {
4393
+ allRows.push({ kind: "item", item, globalIdx });
4394
+ globalIdx += 1;
4395
+ }
4396
+ });
4397
+
4398
+ let visibleRows: typeof allRows;
4399
+ if (visibleListState.searching && allRows.length === 0) {
4400
+ visibleRows = [{ kind: "spinner", id: "searching", label: "Searching…" }];
4401
+ } else if (allRows.length === 0) {
4402
+ visibleRows = [{ kind: "message", id: "empty", label: visibleListState.emptyLabel }];
4403
+ } else {
4404
+ const selectedRowIdx = allRows.findIndex((row) => row.kind === "item" && row.globalIdx === visibleListState.selectedIdx);
4405
+ const halfWindow = Math.floor(bodyHeight / 2);
4406
+ let windowStart = Math.max(0, Math.min(selectedRowIdx - halfWindow, allRows.length - bodyHeight));
4407
+ if (windowStart < 0) windowStart = 0;
4408
+ visibleRows = allRows.slice(windowStart, windowStart + bodyHeight);
4409
+ if (visibleListState.searching) {
4410
+ if (visibleRows.length >= bodyHeight) {
4411
+ visibleRows = visibleRows.slice(0, bodyHeight - 1);
4412
+ }
4413
+ visibleRows.push({ kind: "spinner", id: "searching", label: "Searching…" });
4414
+ }
4415
+ }
4416
+
4417
+ while (visibleRows.length < bodyHeight) {
4418
+ visibleRows.push({ kind: "filler", id: `filler:${visibleRows.length}` });
4419
+ }
4420
+
4421
+ return (
4422
+ <box
4423
+ flexDirection="column"
4424
+ height={bodyHeight}
4425
+ onMouseScroll={handleListScroll}
4426
+ >
4427
+ {visibleRows.map((row) => {
4428
+ if (row.kind === "filler" || row.kind === "spacer") {
4429
+ return <box key={row.id} height={1} />;
4430
+ }
4431
+ if (row.kind === "spinner") {
4432
+ return (
4433
+ <box key={row.id} height={1} paddingX={contentPadding} onMouseScroll={handleListScroll}>
4434
+ <Spinner label={row.label} />
4435
+ </box>
4436
+ );
4437
+ }
4438
+ if (row.kind === "message") {
4439
+ return (
4440
+ <box key={row.id} height={1} paddingX={contentPadding} onMouseScroll={handleListScroll}>
4441
+ <text fg={paletteText}>{truncateText(row.label, barWidth - contentPadding * 2)}</text>
4442
+ </box>
4443
+ );
4444
+ }
4445
+ if (row.kind === "heading") {
4446
+ return (
4447
+ <box key={row.id} height={1} paddingX={contentPadding} onMouseScroll={handleListScroll}>
4448
+ <text attributes={TextAttributes.BOLD} fg={paletteHeadingText}>
4449
+ {truncateText(row.label, barWidth - contentPadding * 2)}
4450
+ </text>
4451
+ </box>
4452
+ );
4453
+ }
4454
+
4455
+ const isSelected = row.globalIdx === visibleListState.selectedIdx;
4456
+ const isHovered = row.globalIdx === visibleListState.hoveredIdx && !isSelected;
4457
+ const presentation = getRowPresentation(row.item, isSelected, trailingWidth > 0);
4458
+ const label = truncateText(presentation.label, labelWidth);
4459
+ const trailing = truncateText(presentation.trailing, trailingWidth);
4460
+ return (
4461
+ <box
4462
+ key={row.item.id}
4463
+ flexDirection="row"
4464
+ height={1}
4465
+ paddingX={contentPadding}
4466
+ backgroundColor={isSelected ? paletteSelectedBg : isHovered ? paletteHoverBg : paletteBg}
4467
+ onMouseMove={() => setHoveredIndex(row.globalIdx)}
4468
+ onMouseScroll={handleListScroll}
4469
+ onMouseDown={(event: any) => {
4470
+ event.stopPropagation?.();
4471
+ event.preventDefault?.();
4472
+ if (!currentRoute) {
4473
+ setRootSelectedIdx(row.globalIdx);
4474
+ } else {
4475
+ updateTopRoute((route) => {
4476
+ if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
4477
+ return { ...route, selectedIdx: row.globalIdx, hoveredIdx: row.globalIdx };
4478
+ }
4479
+ return route;
4480
+ });
4481
+ }
4482
+ activateListSelection({ item: row.item });
4483
+ }}
4484
+ >
4485
+ <box width={labelWidth}>
4486
+ <text fg={isSelected ? paletteSelectedText : presentation.primaryMuted ? paletteSubtleText : paletteText}>
4487
+ {label}
4488
+ </text>
4489
+ </box>
4490
+ <box width={trailingWidth}>
4491
+ <text fg={isSelected ? paletteSelectedText : paletteSubtleText}>{trailing}</text>
4492
+ </box>
4493
+ </box>
4494
+ );
4495
+ })}
4496
+ </box>
4497
+ );
4498
+ };
4499
+
4500
+ const renderWorkflowBody = () => {
4501
+ if (currentRoute?.kind !== "workflow") return null;
4502
+ const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
4503
+ return (
4504
+ <box flexDirection="column" height={bodyHeight} paddingX={contentPadding}>
4505
+ {currentRoute.subtitle && (
4506
+ <box height={1}>
4507
+ <text fg={paletteSubtleText}>{truncateText(currentRoute.subtitle, barWidth - contentPadding * 2)}</text>
4508
+ </box>
4509
+ )}
4510
+ {currentRoute.description?.map((line, index) => (
4511
+ <box key={`workflow-desc:${index}`} height={1}>
4512
+ <text fg={paletteSubtleText}>{truncateText(line, barWidth - contentPadding * 2)}</text>
4513
+ </box>
4514
+ ))}
4515
+ {currentRoute.subtitle || (currentRoute.description?.length ?? 0) > 0 ? <box height={1} /> : null}
4516
+ {visibleFields.map((field) => {
4517
+ const active = field.id === currentRoute.activeFieldId;
4518
+ const value = currentRoute.values[field.id];
4519
+ const borderColor = active ? paletteSelectedBg : paletteBg;
4520
+ return (
4521
+ <box
4522
+ key={field.id}
4523
+ flexDirection="column"
4524
+ marginBottom={1}
4525
+ backgroundColor={active ? colors.panel : paletteBg}
4526
+ onMouseDown={(event: any) => {
4527
+ event.stopPropagation?.();
4528
+ syncActiveWorkflowTextarea(currentRoute);
4529
+ updateTopRoute((route) => route.kind === "workflow"
4530
+ ? { ...route, activeFieldId: field.id, error: null }
4531
+ : route);
4532
+ if (!isWorkflowTextField(field)) {
4533
+ openWorkflowFieldPicker(currentRoute, field);
4534
+ }
4535
+ }}
4536
+ >
4537
+ <box height={1}>
4538
+ <text fg={active ? paletteText : paletteSubtleText} attributes={active ? TextAttributes.BOLD : 0}>
4539
+ {field.label}
4540
+ </text>
4541
+ </box>
4542
+ {isWorkflowTextField(field) ? (
4543
+ field.type === "number" ? (
4544
+ <NumberField
4545
+ inputRef={getInputRef(workflowInputRefs.current, field.id) as RefObject<InputRenderable | null>}
4546
+ value={coerceFieldString(value)}
4547
+ placeholder={field.placeholder}
4548
+ focused={active && !currentRoute.pending}
4549
+ onChange={(nextValue) => updateWorkflowValue(field.id, nextValue)}
4550
+ onSubmit={() => {
4551
+ const index = visibleFields.findIndex((entry) => entry.id === field.id);
4552
+ if (index === visibleFields.length - 1) {
4553
+ void submitWorkflowRoute(currentRoute);
4554
+ } else {
4555
+ moveWorkflowFocus(1);
4556
+ }
4557
+ }}
4558
+ />
4559
+ ) : field.type === "textarea" ? (
4560
+ <box
4561
+ minHeight={6}
4562
+ height={6}
4563
+ border
4564
+ borderColor={active ? paletteSelectedBg : paletteBg}
4565
+ backgroundColor={active ? colors.panel : paletteBg}
4566
+ >
4567
+ {active ? (
4568
+ <textarea
4569
+ key={field.id}
4570
+ ref={getInputRef(workflowInputRefs.current, field.id) as RefObject<TextareaRenderable | null>}
4571
+ initialValue={coerceFieldString(value)}
4572
+ placeholder={field.placeholder || ""}
4573
+ focused={!currentRoute.pending}
4574
+ textColor={paletteText}
4575
+ placeholderColor={paletteSubtleText}
4576
+ backgroundColor={colors.panel}
4577
+ flexGrow={1}
4578
+ />
4579
+ ) : (
4580
+ <box flexDirection="column" paddingX={1} paddingY={0}>
4581
+ {(() => {
4582
+ const preview = coerceFieldString(value).trim();
4583
+ const lines = (preview || field.placeholder || "Unset")
4584
+ .split("\n")
4585
+ .flatMap((line) => line.match(new RegExp(`.{1,${Math.max(1, barWidth - contentPadding * 2 - 8)}}`, "g")) ?? [""])
4586
+ .slice(0, 4);
4587
+ return lines.map((line, index) => (
4588
+ <box key={`${field.id}:preview:${index}`} height={1}>
4589
+ <text fg={preview ? paletteText : paletteSubtleText}>{line || " "}</text>
4590
+ </box>
4591
+ ));
4592
+ })()}
4593
+ </box>
4594
+ )}
4595
+ </box>
4596
+ ) : (
4597
+ <TextField
4598
+ inputRef={getInputRef(workflowInputRefs.current, field.id) as RefObject<InputRenderable | null>}
4599
+ type={field.type === "password" ? "password" : "text"}
4600
+ value={coerceFieldString(value)}
4601
+ placeholder={field.placeholder}
4602
+ focused={active && !currentRoute.pending}
4603
+ onChange={(nextValue) => updateWorkflowValue(field.id, nextValue)}
4604
+ onSubmit={() => {
4605
+ const index = visibleFields.findIndex((entry) => entry.id === field.id);
4606
+ if (index === visibleFields.length - 1) {
4607
+ void submitWorkflowRoute(currentRoute);
4608
+ } else {
4609
+ moveWorkflowFocus(1);
4610
+ }
4611
+ }}
4612
+ />
4613
+ )
4614
+ ) : (
4615
+ <box
4616
+ height={1}
4617
+ backgroundColor={borderColor}
4618
+ onMouseDown={(event: any) => {
4619
+ event.stopPropagation?.();
4620
+ openWorkflowFieldPicker(currentRoute, field);
4621
+ }}
4622
+ >
4623
+ <text fg={active ? paletteText : paletteSubtleText}>
4624
+ {truncateText(summarizeWorkflowFieldValue(field, value), barWidth - contentPadding * 2)}
4625
+ </text>
4626
+ </box>
4627
+ )}
4628
+ {field.description && (
4629
+ <box height={1}>
4630
+ <text fg={paletteSubtleText}>
4631
+ {truncateText(
4632
+ field.type === "textarea" && active
4633
+ ? `${field.description} Ctrl+S submits.`
4634
+ : field.description,
4635
+ barWidth - contentPadding * 2,
4636
+ )}
4637
+ </text>
4638
+ </box>
4639
+ )}
4640
+ </box>
4641
+ );
4642
+ })}
4643
+ {currentRoute.error && (
4644
+ <box height={1}>
4645
+ <text fg={colors.negative}>{truncateText(currentRoute.error, barWidth - contentPadding * 2)}</text>
4646
+ </box>
4647
+ )}
4648
+ {currentRoute.pendingLabel && currentRoute.pending && (
4649
+ <box height={1}>
4650
+ <Spinner label={currentRoute.pendingLabel} />
4651
+ </box>
4652
+ )}
4653
+ <box flexGrow={1} />
4654
+ <box flexDirection="row" gap={1} justifyContent={visibleFields.some((field) => field.type === "textarea") ? "flex-end" : "flex-start"}>
4655
+ <Button label={currentRoute.submitLabel} variant="primary" onPress={() => { void submitWorkflowRoute(currentRoute); }} disabled={currentRoute.pending} />
4656
+ </box>
4657
+ </box>
4658
+ );
4659
+ };
4660
+
4661
+ const renderConfirmBody = () => {
4662
+ if (currentRoute?.kind !== "confirm") return null;
4663
+ return (
4664
+ <box flexDirection="column" height={bodyHeight} paddingX={contentPadding}>
4665
+ {currentRoute.body.map((line, index) => (
4666
+ <box key={`confirm:${index}`} height={1}>
4667
+ <text fg={paletteText}>{truncateText(line, barWidth - contentPadding * 2)}</text>
4668
+ </box>
4669
+ ))}
4670
+ <box height={1} />
4671
+ {currentRoute.error && (
4672
+ <box height={1}>
4673
+ <text fg={colors.negative}>{truncateText(currentRoute.error, barWidth - contentPadding * 2)}</text>
4674
+ </box>
4675
+ )}
4676
+ {currentRoute.pending && (
4677
+ <box height={1}>
4678
+ <Spinner label="Working…" />
4679
+ </box>
4680
+ )}
4681
+ <box flexGrow={1} />
4682
+ <box flexDirection="row" gap={1}>
4683
+ <Button
4684
+ label={currentRoute.confirmLabel}
4685
+ variant={currentRoute.tone === "danger" ? "danger" : "primary"}
4686
+ onPress={() => { void confirmCurrentRoute(); }}
4687
+ disabled={currentRoute.pending}
4688
+ />
4689
+ </box>
4690
+ </box>
4691
+ );
4692
+ };
4693
+
4694
+ const renderMultiSelectBody = () => {
4695
+ if (currentRoute?.kind !== "picker" || currentRoute.pickerId !== "field-multi-select") return null;
4696
+ const selectedValues = coerceFieldValues(currentRoute.payload?.selectedValues as CommandBarFieldValue | undefined);
4697
+ const options = getVisibleMultiSelectPickerOptions(currentRoute);
4698
+ const items = options.map((option) => ({
4699
+ id: option.id,
4700
+ label: option.label,
4701
+ enabled: selectedValues.includes(option.id),
4702
+ description: option.description || option.detail,
4703
+ }));
4704
+ const selectedIdx = items.length === 0
4705
+ ? 0
4706
+ : Math.max(0, Math.min(currentRoute.selectedIdx, items.length - 1));
4707
+ return (
4708
+ <box flexDirection="column" height={bodyHeight} paddingX={contentPadding}>
4709
+ <ToggleList
4710
+ items={items}
4711
+ selectedIdx={selectedIdx}
4712
+ flexGrow={1}
4713
+ scrollable
4714
+ showSelectedDescription={false}
4715
+ onSelect={(index) => {
4716
+ updateTopRoute((route) => route.kind === "picker"
4717
+ ? { ...route, selectedIdx: index, hoveredIdx: null }
4718
+ : route);
4719
+ }}
4720
+ onToggle={(id) => handleMultiSelectToggle(id)}
4721
+ bgColor={paletteBg}
4722
+ />
4723
+ <box flexDirection="row" gap={1}>
4724
+ <Button label="Done" variant="primary" onPress={commitMultiSelectPicker} />
4725
+ </box>
4726
+ </box>
4727
+ );
4728
+ };
4729
+
4730
+ return (
4731
+ <box
4732
+ position="absolute"
4733
+ top={0}
4734
+ left={0}
4735
+ width={termWidth}
4736
+ height={termHeight}
4737
+ zIndex={100}
4738
+ onMouseDown={(event: any) => {
4739
+ event.stopPropagation?.();
4740
+ event.preventDefault?.();
4741
+ closeAll();
4742
+ }}
4743
+ >
4744
+ <box
4745
+ position="absolute"
4746
+ top={barTop}
4747
+ left={barLeft}
4748
+ width={barWidth}
4749
+ height={barHeight}
4750
+ flexDirection="column"
4751
+ backgroundColor={paletteBg}
4752
+ zIndex={101}
4753
+ onMouseDown={(event: any) => {
4754
+ event.stopPropagation?.();
4755
+ }}
4756
+ >
4757
+ <box height={1} />
4758
+
4759
+ <box height={1} paddingX={contentPadding} flexDirection="row" alignItems="center">
4760
+ {currentRoute && (
4761
+ <box marginRight={1}>
4762
+ <Button label="Back" variant="ghost" onPress={popRoute} />
4763
+ </box>
4764
+ )}
4765
+ <box flexGrow={1}>
4766
+ <text fg={paletteText} attributes={TextAttributes.BOLD}>
4767
+ {currentRoute?.kind === "mode"
4768
+ ? currentRoute.screen === "themes" ? "Change Theme"
4769
+ : currentRoute.screen === "plugins" ? "Manage Plugins"
4770
+ : currentRoute.screen === "layout" ? "Layout Actions"
4771
+ : currentRoute.screen === "new-pane" ? "New Pane"
4772
+ : "Search Ticker"
4773
+ : currentRoute?.kind === "picker" ? currentRoute.title
4774
+ : currentRoute?.kind === "pane-settings" ? "Pane Settings"
4775
+ : currentRoute?.kind === "workflow" ? currentRoute.title
4776
+ : currentRoute?.kind === "confirm" ? currentRoute.title
4777
+ : "Commands"}
4778
+ </text>
4779
+ </box>
4780
+ </box>
4781
+
4782
+ <box key={bodySlotKey} flexDirection="column" flexGrow={1} width="100%" backgroundColor={paletteBg}>
4783
+ {(visibleListState || currentRoute?.kind === "picker") && visibleListState && (
4784
+ <>
4785
+ <box height={1} paddingX={contentPadding}>
4786
+ <box width={queryDisplayWidth} height={1} position="relative">
4787
+ <input
4788
+ value={visibleListState.query}
4789
+ onInput={setActiveListQuery}
4790
+ onChange={setActiveListQuery}
4791
+ placeholder={visibleListState.kind === "root" ? "Search" : "Filter"}
4792
+ focused
4793
+ width={queryDisplayWidth}
4794
+ backgroundColor={paletteBg}
4795
+ focusedBackgroundColor={paletteBg}
4796
+ textColor={paletteText}
4797
+ focusedTextColor={paletteText}
4798
+ placeholderColor={paletteSubtleText}
4799
+ cursorColor={colors.textBright}
4800
+ />
4801
+ {visibleListState.kind === "root" && rootGhostSuffix && (
4802
+ <box
4803
+ position="absolute"
4804
+ top={0}
4805
+ left={Math.max(0, Math.min(rootQuery.length, queryDisplayWidth - 1))}
4806
+ width={Math.max(0, queryDisplayWidth - Math.min(rootQuery.length, queryDisplayWidth - 1))}
4807
+ height={1}
4808
+ >
4809
+ <text fg={paletteSubtleText}>
4810
+ {truncateText(
4811
+ rootGhostSuffix,
4812
+ Math.max(0, queryDisplayWidth - Math.min(rootQuery.length, queryDisplayWidth - 1)),
4813
+ )}
4814
+ </text>
4815
+ </box>
4816
+ )}
4817
+ </box>
4818
+ </box>
4819
+ <box height={1} paddingX={contentPadding}>
4820
+ {visibleListState.kind === "root" && rootShortcutFeedback
4821
+ ? (
4822
+ <text fg={paletteSubtleText}>
4823
+ {truncateText(rootShortcutFeedback, barWidth - contentPadding * 2)}
4824
+ </text>
4825
+ )
4826
+ : null}
4827
+ </box>
4828
+ </>
4829
+ )}
4830
+
4831
+ {visibleListState && !showCustomMultiSelectPicker && renderListBody()}
4832
+ {currentRoute?.kind === "workflow" && renderWorkflowBody()}
4833
+ {currentRoute?.kind === "confirm" && renderConfirmBody()}
4834
+ {showCustomMultiSelectPicker && renderMultiSelectBody()}
4835
+ </box>
4836
+
4837
+ <box flexGrow={1} />
4838
+ </box>
4839
+ </box>
4840
+ );
4841
+ }