gloomberb 0.2.0 → 0.3.1

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