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,1524 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useReducer } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DialogProvider } from "@opentui-ui/dialog/react";
5
+ import { CommandBar } from "./command-bar";
6
+ import { AppContext, type AppState, appReducer, createInitialState } from "../../state/app-context";
7
+ import { createTestDataProvider } from "../../test-support/data-provider";
8
+ import { cloneLayout, createDefaultConfig, type AppConfig } from "../../types/config";
9
+ import type { DataProvider } from "../../types/data-provider";
10
+ import type { TickerRecord } from "../../types/ticker";
11
+ import type { PluginRegistry } from "../../plugins/registry";
12
+
13
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
14
+
15
+ afterEach(() => {
16
+ if (testSetup) {
17
+ testSetup.renderer.destroy();
18
+ testSetup = undefined;
19
+ }
20
+ });
21
+
22
+ async function waitForFrameToContain(text: string, attempts = 12, delayMs = 50): Promise<string> {
23
+ for (let attempt = 0; attempt < attempts; attempt++) {
24
+ const frame = testSetup!.captureCharFrame();
25
+ if (frame.includes(text)) {
26
+ return frame;
27
+ }
28
+ await Bun.sleep(delayMs);
29
+ await testSetup!.renderOnce();
30
+ }
31
+ throw new Error(`Timed out waiting for frame to contain "${text}".`);
32
+ }
33
+
34
+ async function clickFrameText(text: string): Promise<void> {
35
+ const frame = testSetup!.captureCharFrame();
36
+ const rows = frame.split("\n");
37
+ const row = rows.findIndex((line) => line.includes(text));
38
+ const col = row >= 0 ? rows[row]!.indexOf(text) : -1;
39
+
40
+ expect(row).toBeGreaterThanOrEqual(0);
41
+ expect(col).toBeGreaterThanOrEqual(0);
42
+
43
+ await act(async () => {
44
+ await testSetup!.mockMouse.click(col + 1, row);
45
+ await testSetup!.renderOnce();
46
+ });
47
+ }
48
+
49
+ function expectSingleBackControl(frame: string): void {
50
+ expect(frame.match(/\bBack\b/g)?.length ?? 0).toBe(1);
51
+ expect(frame).not.toContain("esc");
52
+ }
53
+
54
+ function makeTicker(symbol: string, name: string): TickerRecord {
55
+ return {
56
+ metadata: {
57
+ ticker: symbol,
58
+ exchange: "NASDAQ",
59
+ currency: "USD",
60
+ name,
61
+ portfolios: [],
62
+ watchlists: [],
63
+ positions: [],
64
+ custom: {},
65
+ tags: [],
66
+ },
67
+ };
68
+ }
69
+
70
+ function makeDataProvider(searchImpl: DataProvider["search"] = async () => []): DataProvider {
71
+ return createTestDataProvider({
72
+ id: "test",
73
+ search: searchImpl,
74
+ });
75
+ }
76
+
77
+ function makePluginRegistry(hasPaneSettings: (paneId: string) => boolean = () => false): PluginRegistry {
78
+ return {
79
+ panes: new Map<string, any>([
80
+ ["portfolio-list", {
81
+ id: "portfolio-list",
82
+ name: "Portfolio List",
83
+ component: () => null,
84
+ defaultPosition: "left",
85
+ }],
86
+ ["ticker-detail", {
87
+ id: "ticker-detail",
88
+ name: "Ticker Detail",
89
+ component: () => null,
90
+ defaultPosition: "right",
91
+ }],
92
+ ["chat", {
93
+ id: "chat",
94
+ name: "Chat",
95
+ component: () => null,
96
+ defaultPosition: "right",
97
+ defaultMode: "floating",
98
+ }],
99
+ ["quote-monitor", {
100
+ id: "quote-monitor",
101
+ name: "Quote Monitor",
102
+ component: () => null,
103
+ defaultPosition: "right",
104
+ defaultMode: "floating",
105
+ }],
106
+ ["ibkr-trading", {
107
+ id: "ibkr-trading",
108
+ name: "IBKR Console",
109
+ component: () => null,
110
+ defaultPosition: "right",
111
+ defaultMode: "floating",
112
+ }],
113
+ ]),
114
+ paneTemplates: new Map<string, any>([
115
+ ["new-portfolio-pane", {
116
+ id: "new-portfolio-pane",
117
+ paneId: "portfolio-list",
118
+ label: "New Portfolio Pane",
119
+ description: "Open another portfolio list pane",
120
+ shortcut: { prefix: "PF" },
121
+ }],
122
+ ["new-watchlist-pane", {
123
+ id: "new-watchlist-pane",
124
+ paneId: "portfolio-list",
125
+ label: "New Watchlist Pane",
126
+ description: "Open another watchlist pane",
127
+ }],
128
+ ["new-ticker-detail-pane", {
129
+ id: "new-ticker-detail-pane",
130
+ paneId: "ticker-detail",
131
+ label: "New Ticker Detail Pane",
132
+ description: "Open another detail pane",
133
+ }],
134
+ ["new-chat-pane", {
135
+ id: "new-chat-pane",
136
+ paneId: "chat",
137
+ label: "New Chat Pane",
138
+ description: "Open another floating chat window",
139
+ shortcut: { prefix: "CHAT" },
140
+ }],
141
+ ["quote-monitor-pane", {
142
+ id: "quote-monitor-pane",
143
+ paneId: "quote-monitor",
144
+ label: "Quote Monitor",
145
+ description: "Open a compact quote monitor for the selected ticker",
146
+ shortcut: { prefix: "QQ", argPlaceholder: "ticker" },
147
+ }],
148
+ ["new-ibkr-trading-pane", {
149
+ id: "new-ibkr-trading-pane",
150
+ paneId: "ibkr-trading",
151
+ label: "New IBKR Trading Pane",
152
+ description: "Open another floating IBKR trading console",
153
+ shortcut: { prefix: "IBKR" },
154
+ canCreate: (context: any) => context.config.brokerInstances.some((instance: any) => (
155
+ instance.brokerType === "ibkr"
156
+ && instance.connectionMode === "gateway"
157
+ && instance.enabled !== false
158
+ )),
159
+ }],
160
+ ]),
161
+ commands: new Map<string, any>([
162
+ ["plugin:scan", {
163
+ id: "plugin:scan",
164
+ label: "Scan Movers",
165
+ description: "Run a quick movers scan",
166
+ category: "plugins",
167
+ execute: async () => {},
168
+ }],
169
+ ]),
170
+ tickerActions: new Map<string, any>([
171
+ ["pin", {
172
+ id: "pin",
173
+ label: "Pin Ticker",
174
+ execute: async () => {},
175
+ }],
176
+ ]),
177
+ brokers: new Map(),
178
+ allPlugins: new Map([
179
+ ["news", { id: "news", name: "News", version: "1.0.0", description: "Latest headlines", toggleable: true }],
180
+ ["notes", { id: "notes", name: "Notes", version: "1.0.0", description: "Ticker notes", toggleable: true }],
181
+ ]),
182
+ getCommandPluginId: () => "news",
183
+ getPaneTemplatePluginId: (templateId: string) => (
184
+ templateId === "new-chat-pane" ? "news" : "notes"
185
+ ),
186
+ getPluginPaneIds: () => [],
187
+ getPluginPaneTemplateIds: () => [],
188
+ hasPaneSettings,
189
+ events: { emit: () => {} },
190
+ hideWidget: () => {},
191
+ pinTickerFn: () => {},
192
+ showWidget: () => {},
193
+ updateLayoutFn: () => {},
194
+ getTermSizeFn: () => ({ width: 80, height: 24 }),
195
+ showToastFn: () => {},
196
+ createPaneFromTemplateFn: () => {},
197
+ createPaneFromTemplateAsyncFn: async () => {},
198
+ openPaneSettingsFn: () => {},
199
+ applyPaneSettingValueFn: async () => {},
200
+ resolvePaneSettings: () => null,
201
+ createBrokerInstanceFn: async () => { throw new Error("unused"); },
202
+ syncBrokerInstanceFn: async () => {},
203
+ removeBrokerInstanceFn: async () => {},
204
+ getConfigFn: () => createDefaultConfig("/tmp/gloomberb-test"),
205
+ } as unknown as PluginRegistry;
206
+ }
207
+
208
+ function CommandBarHarness({
209
+ query,
210
+ disabledPlugins = [],
211
+ selectedTicker,
212
+ live = false,
213
+ extraTickers = [],
214
+ showQueryState = false,
215
+ onSaveTicker,
216
+ configureConfig,
217
+ configureState,
218
+ configurePluginRegistry,
219
+ dataProvider = makeDataProvider(),
220
+ hasPaneSettings,
221
+ }: {
222
+ query: string;
223
+ disabledPlugins?: string[];
224
+ selectedTicker?: string;
225
+ live?: boolean;
226
+ extraTickers?: TickerRecord[];
227
+ showQueryState?: boolean;
228
+ onSaveTicker?: (ticker: TickerRecord) => void;
229
+ configureConfig?: (config: AppConfig) => AppConfig;
230
+ configureState?: (state: AppState) => AppState;
231
+ configurePluginRegistry?: (pluginRegistry: PluginRegistry) => void;
232
+ dataProvider?: DataProvider;
233
+ hasPaneSettings?: (paneId: string) => boolean;
234
+ }) {
235
+ let config = {
236
+ ...createDefaultConfig("/tmp/gloomberb-test"),
237
+ recentTickers: ["AAPL", "MSFT"],
238
+ disabledPlugins,
239
+ };
240
+ if (configureConfig) {
241
+ config = configureConfig(config);
242
+ }
243
+ const tickers = [makeTicker("AAPL", "Apple Inc."), makeTicker("MSFT", "Microsoft Corp."), ...extraTickers];
244
+ let state: AppState = {
245
+ ...createInitialState(config),
246
+ commandBarOpen: true,
247
+ commandBarQuery: query,
248
+ tickers: new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker])),
249
+ config: { ...config, disabledPlugins },
250
+ paneState: selectedTicker
251
+ ? {
252
+ "portfolio-list:main": {
253
+ collectionId: "main",
254
+ cursorSymbol: selectedTicker,
255
+ },
256
+ }
257
+ : createInitialState(config).paneState,
258
+ };
259
+ if (configureState) {
260
+ state = configureState(state);
261
+ }
262
+ const tickerRepository = {
263
+ loadTicker: async () => null,
264
+ createTicker: async (metadata: TickerRecord["metadata"]) => ({
265
+ metadata,
266
+ }),
267
+ saveTicker: async (ticker: TickerRecord) => {
268
+ onSaveTicker?.(ticker);
269
+ },
270
+ };
271
+ const pluginRegistry = makePluginRegistry(hasPaneSettings);
272
+ configurePluginRegistry?.(pluginRegistry);
273
+ const [liveState, dispatch] = useReducer(appReducer, state);
274
+ const currentState = live ? liveState : state;
275
+ const currentDispatch = live ? dispatch : () => {};
276
+
277
+ return (
278
+ <AppContext value={{ state: currentState, dispatch: currentDispatch }}>
279
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
280
+ {live && <text>{`theme:${currentState.config.theme}`}</text>}
281
+ {showQueryState && <text>{`query:${currentState.commandBarQuery}`}</text>}
282
+ {currentState.commandBarOpen && (
283
+ <CommandBar
284
+ dataProvider={dataProvider}
285
+ tickerRepository={tickerRepository as any}
286
+ pluginRegistry={pluginRegistry}
287
+ quitApp={() => {}}
288
+ />
289
+ )}
290
+ </DialogProvider>
291
+ </AppContext>
292
+ );
293
+ }
294
+
295
+ describe("CommandBar", () => {
296
+ test("renders the default layout with opencode-style chrome", async () => {
297
+ testSetup = await testRender(<CommandBarHarness query="" selectedTicker="AAPL" />, {
298
+ width: 80,
299
+ height: 24,
300
+ });
301
+
302
+ await testSetup.renderOnce();
303
+
304
+ expect(testSetup.captureCharFrame()).toMatchSnapshot();
305
+ });
306
+
307
+ test("renders theme prefix results in the root query", async () => {
308
+ testSetup = await testRender(<CommandBarHarness query="TH " />, {
309
+ width: 80,
310
+ height: 24,
311
+ });
312
+
313
+ await testSetup.renderOnce();
314
+
315
+ const frame = testSetup.captureCharFrame();
316
+ expect(frame).toContain("Commands");
317
+ expect(frame).toContain("TH");
318
+ expect(frame).toContain("Themes");
319
+ expect(frame).toMatchSnapshot();
320
+ });
321
+
322
+ test("renders plugin toggle state", async () => {
323
+ testSetup = await testRender(<CommandBarHarness query="PL " disabledPlugins={["notes"]} />, {
324
+ width: 80,
325
+ height: 24,
326
+ });
327
+
328
+ await testSetup.renderOnce();
329
+
330
+ expect(testSetup.captureCharFrame()).toMatchSnapshot();
331
+ });
332
+
333
+ test("only surfaces PS when the focused pane exposes settings", async () => {
334
+ testSetup = await testRender(
335
+ <CommandBarHarness query="PS" hasPaneSettings={(paneId) => paneId === "portfolio-list:main"} />,
336
+ { width: 80, height: 24 },
337
+ );
338
+
339
+ await testSetup.renderOnce();
340
+ expect(testSetup.captureCharFrame()).toContain("Pane Settings");
341
+
342
+ testSetup.renderer.destroy();
343
+ testSetup = await testRender(<CommandBarHarness query="PS" hasPaneSettings={() => false} />, {
344
+ width: 80,
345
+ height: 24,
346
+ });
347
+
348
+ await testSetup.renderOnce();
349
+ expect(testSetup.captureCharFrame()).not.toContain("Pane Settings");
350
+ });
351
+
352
+ test("collapses the trailing column on narrow terminals", async () => {
353
+ testSetup = await testRender(<CommandBarHarness query="" selectedTicker="AAPL" />, {
354
+ width: 42,
355
+ height: 20,
356
+ });
357
+
358
+ await testSetup.renderOnce();
359
+
360
+ expect(testSetup.captureCharFrame()).toMatchSnapshot();
361
+ });
362
+
363
+ test("moves the theme selector with arrow keys", async () => {
364
+ testSetup = await testRender(<CommandBarHarness query="TH " live />, {
365
+ width: 80,
366
+ height: 24,
367
+ });
368
+
369
+ await testSetup.renderOnce();
370
+ await act(async () => {
371
+ testSetup!.mockInput.pressArrow("down");
372
+ await testSetup!.renderOnce();
373
+ });
374
+ await testSetup.renderOnce();
375
+
376
+ const frame = testSetup.captureCharFrame();
377
+ expect(frame).toContain("theme:green");
378
+ });
379
+
380
+ test("keeps typed prefixes in the root query until a result is activated", async () => {
381
+ testSetup = await testRender(<CommandBarHarness query="T " />, {
382
+ width: 80,
383
+ height: 24,
384
+ });
385
+
386
+ await testSetup.renderOnce();
387
+
388
+ let frame = testSetup.captureCharFrame();
389
+ expect(frame).toContain("Commands");
390
+ expect(frame).toContain("T");
391
+ expect(frame).toContain("Type a ticker symbol");
392
+ expect(frame).not.toContain("Back");
393
+ });
394
+
395
+ test("QQ without an active ticker opens inline ticker search on enter", async () => {
396
+ testSetup = await testRender(<CommandBarHarness query="QQ" />, {
397
+ width: 100,
398
+ height: 20,
399
+ });
400
+
401
+ await testSetup.renderOnce();
402
+ expect(testSetup.captureCharFrame()).toContain("Quote Monitor");
403
+ expect(testSetup.captureCharFrame()).not.toContain("Back");
404
+
405
+ await act(async () => {
406
+ testSetup!.mockInput.pressEnter();
407
+ await testSetup!.renderOnce();
408
+ });
409
+
410
+ const frame = testSetup.captureCharFrame();
411
+ expect(frame).toContain("Back");
412
+ expect(frame).toContain("Search Ticker");
413
+ });
414
+
415
+ test("QQ with an active ticker shows ghost completion and tab inserts the symbol", async () => {
416
+ testSetup = await testRender(<CommandBarHarness query="QQ" live selectedTicker="AAPL" showQueryState />, {
417
+ width: 100,
418
+ height: 20,
419
+ });
420
+
421
+ await testSetup.renderOnce();
422
+ expect(testSetup.captureCharFrame()).toContain("QQ AAPL");
423
+ expect(testSetup.captureCharFrame()).toContain("Shortcut: Quote Monitor for AAPL");
424
+ expect(testSetup.captureCharFrame()).toContain("query:QQ");
425
+
426
+ await act(async () => {
427
+ testSetup!.mockInput.pressTab();
428
+ await testSetup!.renderOnce();
429
+ });
430
+
431
+ const frame = testSetup.captureCharFrame();
432
+ expect(frame).toContain("query:QQ AAPL");
433
+ });
434
+
435
+ test("typing a shorthand and pressing enter executes the inferred quote monitor shortcut", async () => {
436
+ const created: Array<{ templateId: string; options?: Record<string, unknown> }> = [];
437
+
438
+ testSetup = await testRender(<CommandBarHarness
439
+ query=""
440
+ live
441
+ selectedTicker="AAPL"
442
+ configurePluginRegistry={(pluginRegistry) => {
443
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
444
+ created.push({ templateId, options });
445
+ };
446
+ }}
447
+ />, {
448
+ width: 100,
449
+ height: 20,
450
+ });
451
+
452
+ await testSetup.renderOnce();
453
+
454
+ await act(async () => {
455
+ await testSetup!.mockInput.typeText("QQ");
456
+ testSetup!.mockInput.pressEnter();
457
+ await Bun.sleep(0);
458
+ await testSetup!.renderOnce();
459
+ });
460
+
461
+ expect(created).toEqual([{
462
+ templateId: "quote-monitor-pane",
463
+ options: {
464
+ arg: "AAPL",
465
+ symbol: "AAPL",
466
+ ticker: makeTicker("AAPL", "Apple Inc."),
467
+ },
468
+ }]);
469
+ });
470
+
471
+ test("clears the root query with cmd-backspace", async () => {
472
+ testSetup = await testRender(<CommandBarHarness query="T AMD" />, {
473
+ width: 80,
474
+ height: 24,
475
+ });
476
+
477
+ await testSetup.renderOnce();
478
+ expect(testSetup.captureCharFrame()).toContain("T AMD");
479
+
480
+ await act(async () => {
481
+ testSetup!.mockInput.pressKey("backspace", { meta: true });
482
+ await testSetup!.renderOnce();
483
+ });
484
+
485
+ const frame = testSetup.captureCharFrame();
486
+ expect(frame).toContain("Commands");
487
+ expect(frame).toContain("Search");
488
+ expect(frame).not.toContain("T AMD");
489
+ });
490
+
491
+ test("pressing the close shortcut at the root closes the command bar", async () => {
492
+ testSetup = await testRender(<CommandBarHarness query="" live />, {
493
+ width: 80,
494
+ height: 24,
495
+ });
496
+
497
+ await testSetup.renderOnce();
498
+
499
+ await act(async () => {
500
+ testSetup!.mockInput.pressKey("`");
501
+ await testSetup!.renderOnce();
502
+ });
503
+
504
+ expect(testSetup.captureCharFrame()).not.toContain("Commands");
505
+ });
506
+
507
+ test("loads provider-backed ticker search results in the root command results", async () => {
508
+ testSetup = await testRender(
509
+ <CommandBarHarness
510
+ query="T appl"
511
+ dataProvider={makeDataProvider(async () => [
512
+ { providerId: "yahoo", symbol: "IVSX", name: "Invsivx Holdings", exchange: "NYSE", type: "ETF" },
513
+ { providerId: "yahoo", symbol: "AAPL", name: "Apple Inc", exchange: "NASDAQ", type: "EQUITY" },
514
+ { providerId: "yahoo", symbol: "AMAT", name: "Applied Materials", exchange: "NASDAQ", type: "EQUITY" },
515
+ { providerId: "yahoo", symbol: "AAOI", name: "Applied Optoelectronics", exchange: "NASDAQ", type: "EQUITY" },
516
+ { providerId: "yahoo", symbol: "APP", name: "AppLovin Corp", exchange: "NASDAQ", type: "EQUITY" },
517
+ ])}
518
+ />,
519
+ { width: 80, height: 24 },
520
+ );
521
+
522
+ await testSetup.renderOnce();
523
+ const frame = await waitForFrameToContain("AAOI");
524
+ expect(frame).toContain("Saved");
525
+ expect(frame).toContain("Other Listings");
526
+ expect(frame).toContain("APP");
527
+ expect(frame).toContain("AAOI");
528
+ });
529
+
530
+ test("T MSFT opens an exact ticker directly", async () => {
531
+ const pinned: string[] = [];
532
+
533
+ testSetup = await testRender(
534
+ <CommandBarHarness
535
+ query="T MSFT"
536
+ configurePluginRegistry={(pluginRegistry) => {
537
+ pluginRegistry.pinTickerFn = (symbol) => {
538
+ pinned.push(symbol);
539
+ };
540
+ }}
541
+ />,
542
+ { width: 100, height: 20 },
543
+ );
544
+
545
+ await testSetup.renderOnce();
546
+ await act(async () => {
547
+ testSetup!.mockInput.pressEnter();
548
+ await Bun.sleep(0);
549
+ await testSetup!.renderOnce();
550
+ await testSetup!.renderOnce();
551
+ });
552
+
553
+ expect(pinned).toEqual(["MSFT"]);
554
+ });
555
+
556
+ test("AW AAPL uses the active watchlist target by default", async () => {
557
+ const saved: TickerRecord[] = [];
558
+
559
+ testSetup = await testRender(
560
+ <CommandBarHarness
561
+ query="AW AAPL"
562
+ onSaveTicker={(ticker) => {
563
+ saved.push(ticker);
564
+ }}
565
+ configureState={(state) => ({
566
+ ...state,
567
+ paneState: {
568
+ ...state.paneState,
569
+ "portfolio-list:main": {
570
+ collectionId: "watchlist",
571
+ },
572
+ },
573
+ })}
574
+ />,
575
+ { width: 100, height: 20 },
576
+ );
577
+
578
+ await testSetup.renderOnce();
579
+ await act(async () => {
580
+ testSetup!.mockInput.pressEnter();
581
+ await Bun.sleep(0);
582
+ await testSetup!.renderOnce();
583
+ });
584
+
585
+ expect(saved.at(-1)?.metadata.watchlists).toEqual(["watchlist"]);
586
+ });
587
+
588
+ test("bare AW without a compatible active target opens inline target selection", async () => {
589
+ testSetup = await testRender(
590
+ <CommandBarHarness
591
+ query="AW"
592
+ selectedTicker="AAPL"
593
+ />,
594
+ { width: 100, height: 20 },
595
+ );
596
+
597
+ await testSetup.renderOnce();
598
+ await act(async () => {
599
+ testSetup!.mockInput.pressEnter();
600
+ await testSetup!.renderOnce();
601
+ });
602
+
603
+ const frame = testSetup.captureCharFrame();
604
+ expect(frame).toContain("Add AAPL to Watchlist");
605
+ expect(frame).toContain("Watchlist");
606
+ expect(frame).toContain("Back");
607
+ });
608
+
609
+ test("pressing enter after arrow navigation ignores stale mouse hover", async () => {
610
+ const pinned: string[] = [];
611
+
612
+ testSetup = await testRender(
613
+ <CommandBarHarness
614
+ query=""
615
+ configurePluginRegistry={(pluginRegistry) => {
616
+ pluginRegistry.pinTickerFn = (symbol) => {
617
+ pinned.push(symbol);
618
+ };
619
+ }}
620
+ />,
621
+ { width: 80, height: 24 },
622
+ );
623
+
624
+ await testSetup.renderOnce();
625
+
626
+ const frame = testSetup.captureCharFrame();
627
+ const rows = frame.split("\n");
628
+ const hoveredRow = rows.findIndex((line) => line.includes("AAPL"));
629
+ const hoveredCol = rows[hoveredRow]?.indexOf("AAPL") ?? -1;
630
+
631
+ expect(hoveredRow).toBeGreaterThanOrEqual(0);
632
+ expect(hoveredCol).toBeGreaterThanOrEqual(0);
633
+
634
+ await act(async () => {
635
+ await testSetup!.mockMouse.moveTo(hoveredCol + 1, hoveredRow);
636
+ await testSetup!.renderOnce();
637
+ });
638
+
639
+ await act(async () => {
640
+ testSetup!.mockInput.pressArrow("down");
641
+ await testSetup!.renderOnce();
642
+ });
643
+
644
+ await act(async () => {
645
+ testSetup!.mockInput.pressEnter();
646
+ await Bun.sleep(0);
647
+ await testSetup!.renderOnce();
648
+ });
649
+
650
+ expect(pinned).toEqual(["MSFT"]);
651
+ });
652
+
653
+ test("pressing enter follows the displayed grouped order and opens confirm for the highlighted destructive item", async () => {
654
+ const executed: string[] = [];
655
+
656
+ testSetup = await testRender(
657
+ <CommandBarHarness
658
+ query="delete"
659
+ configurePluginRegistry={(pluginRegistry) => {
660
+ (pluginRegistry.commands as Map<string, any>).set("delete-layout", {
661
+ id: "delete-layout",
662
+ label: "Delete Layout",
663
+ description: "Delete the current layout preset",
664
+ category: "Layout Manager",
665
+ execute: async () => {
666
+ executed.push("delete-layout");
667
+ },
668
+ });
669
+ }}
670
+ />,
671
+ { width: 100, height: 20 },
672
+ );
673
+
674
+ await testSetup.renderOnce();
675
+ const initialFrame = testSetup.captureCharFrame();
676
+ expect(initialFrame).toContain("Delete Layout");
677
+ expect(initialFrame).toContain("Danger");
678
+
679
+ await act(async () => {
680
+ testSetup!.mockInput.pressEnter();
681
+ await testSetup!.renderOnce();
682
+ });
683
+
684
+ let frame = testSetup.captureCharFrame();
685
+ expect(frame).toContain("Delete Layout");
686
+ expect(frame).toContain("Back");
687
+ expectSingleBackControl(frame);
688
+ expect(executed).toEqual([]);
689
+
690
+ await act(async () => {
691
+ testSetup!.mockInput.pressEnter();
692
+ await Bun.sleep(0);
693
+ await testSetup!.renderOnce();
694
+ });
695
+
696
+ expect(executed).toEqual(["delete-layout"]);
697
+ });
698
+
699
+ test("moves through long result lists with the mouse wheel", async () => {
700
+ testSetup = await testRender(
701
+ <CommandBarHarness
702
+ query="NP scratch"
703
+ configurePluginRegistry={(pluginRegistry) => {
704
+ const paneTemplates = pluginRegistry.paneTemplates as Map<string, any>;
705
+ for (let index = 0; index < 20; index++) {
706
+ const suffix = String(index).padStart(2, "0");
707
+ paneTemplates.set(`scratch-${suffix}`, {
708
+ id: `scratch-${suffix}`,
709
+ paneId: "chat",
710
+ label: `Scratch Pane ${suffix}`,
711
+ description: `Open scratch pane ${suffix}`,
712
+ });
713
+ }
714
+ }}
715
+ />,
716
+ { width: 100, height: 18 },
717
+ );
718
+
719
+ await testSetup.renderOnce();
720
+
721
+ const initialFrame = testSetup.captureCharFrame();
722
+ expect(initialFrame).toContain("Scratch Pane 00");
723
+ expect(initialFrame).not.toContain("Scratch Pane 12");
724
+
725
+ const rows = initialFrame.split("\n");
726
+ const scrollRow = rows.findIndex((line) => line.includes("Scratch Pane 00"));
727
+ const scrollCol = rows[scrollRow]?.indexOf("Scratch Pane 00") ?? -1;
728
+
729
+ expect(scrollRow).toBeGreaterThanOrEqual(0);
730
+ expect(scrollCol).toBeGreaterThanOrEqual(0);
731
+
732
+ await act(async () => {
733
+ for (let index = 0; index < 12; index++) {
734
+ await testSetup!.mockMouse.scroll(scrollCol + 1, scrollRow, "down");
735
+ await testSetup!.renderOnce();
736
+ }
737
+ });
738
+
739
+ const scrolledFrame = testSetup.captureCharFrame();
740
+ expect(scrolledFrame).not.toContain("Scratch Pane 00");
741
+ expect(scrolledFrame).toContain("Scratch Pane 12");
742
+ });
743
+
744
+ test("closes when clicking outside the command bar", async () => {
745
+ testSetup = await testRender(<CommandBarHarness query="" live />, {
746
+ width: 80,
747
+ height: 24,
748
+ });
749
+
750
+ await testSetup.renderOnce();
751
+
752
+ await act(async () => {
753
+ await testSetup!.mockMouse.click(0, 0);
754
+ await testSetup!.renderOnce();
755
+ });
756
+ await testSetup.renderOnce();
757
+
758
+ expect(testSetup.captureCharFrame()).not.toContain("Commands");
759
+ });
760
+
761
+ const layoutModeConfig = (config: AppConfig): AppConfig => {
762
+ const research = cloneLayout(config.layout);
763
+ research.dockRoot = { kind: "pane", instanceId: "portfolio-list:main" };
764
+ research.floating = [{ instanceId: "ticker-detail:main", x: 8, y: 2, width: 36, height: 12 }];
765
+ return {
766
+ ...config,
767
+ layouts: [
768
+ { name: "Default", layout: cloneLayout(config.layout) },
769
+ { name: "Research", layout: research },
770
+ ],
771
+ };
772
+ };
773
+
774
+ const layoutModeState = (state: AppState): AppState => ({
775
+ ...state,
776
+ layoutHistory: {
777
+ 0: {
778
+ past: [cloneLayout(state.config.layout)],
779
+ future: [],
780
+ },
781
+ },
782
+ });
783
+
784
+ test("renders layout mode with focused pane actions", async () => {
785
+ testSetup = await testRender(<CommandBarHarness
786
+ query="LAY "
787
+ configureConfig={layoutModeConfig}
788
+ configureState={layoutModeState}
789
+ />, {
790
+ width: 90,
791
+ height: 28,
792
+ });
793
+
794
+ await testSetup.renderOnce();
795
+
796
+ const frame = testSetup.captureCharFrame();
797
+ expect(frame).toContain("Focused Pane");
798
+ expect(frame).toContain("Float Pane");
799
+ expect(frame).toContain("Undo Layout Change");
800
+ expect(frame).toContain("Current Layout");
801
+ });
802
+
803
+ test("renders filtered saved layouts with textual previews", async () => {
804
+ testSetup = await testRender(<CommandBarHarness
805
+ query="LAY Research"
806
+ configureConfig={layoutModeConfig}
807
+ configureState={layoutModeState}
808
+ />, {
809
+ width: 120,
810
+ height: 18,
811
+ });
812
+
813
+ await testSetup.renderOnce();
814
+
815
+ const frame = testSetup.captureCharFrame();
816
+ expect(frame).toContain("Research");
817
+ expect(frame).toContain("1c / 1d");
818
+ });
819
+
820
+ test("renders new pane mode with plugin-defined pane templates", async () => {
821
+ testSetup = await testRender(<CommandBarHarness query="NP chat" />, {
822
+ width: 100,
823
+ height: 18,
824
+ });
825
+
826
+ await testSetup.renderOnce();
827
+
828
+ const frame = testSetup.captureCharFrame();
829
+ expect(frame).toContain("New Chat Pane");
830
+ expect(frame).toContain("float");
831
+ });
832
+
833
+ test("shows pane shortcuts in the default browse results", async () => {
834
+ testSetup = await testRender(<CommandBarHarness query="" selectedTicker="AAPL" />, {
835
+ width: 100,
836
+ height: 24,
837
+ });
838
+
839
+ await testSetup.renderOnce();
840
+
841
+ const frame = testSetup.captureCharFrame();
842
+ expect(frame).toContain("Panes");
843
+ expect(frame).toContain("Quote Monitor");
844
+ expect(frame).toContain("QQ");
845
+ });
846
+
847
+ test("matches direct pane shortcut queries", async () => {
848
+ testSetup = await testRender(<CommandBarHarness query="QQ MSFT" selectedTicker="AAPL" />, {
849
+ width: 100,
850
+ height: 18,
851
+ });
852
+
853
+ await testSetup.renderOnce();
854
+
855
+ const frame = testSetup.captureCharFrame();
856
+ expect(frame).toContain("Quote Monitor");
857
+ expect(frame).toContain("QQ");
858
+ });
859
+
860
+ test("opens plugin command wizards inline inside the command bar", async () => {
861
+ let submittedValues: Record<string, string> | undefined;
862
+
863
+ testSetup = await testRender(<CommandBarHarness
864
+ query="Plugin Login"
865
+ configurePluginRegistry={(pluginRegistry) => {
866
+ (pluginRegistry.commands as Map<string, any>).set("plugin:login", {
867
+ id: "plugin:login",
868
+ label: "Plugin Login",
869
+ description: "Authenticate without leaving the command bar",
870
+ category: "config",
871
+ wizard: [
872
+ { key: "username", label: "Username", type: "text", placeholder: "vince" },
873
+ { key: "password", label: "Password", type: "password", placeholder: "secret" },
874
+ { key: "_validate", label: "Validating", type: "info", body: ["Validating…", "Connected."] },
875
+ ],
876
+ execute: async (values?: Record<string, string>) => {
877
+ submittedValues = values;
878
+ },
879
+ } as any);
880
+ pluginRegistry.getCommandPluginId = (commandId: string) => (
881
+ commandId === "plugin:login" ? "notes" : "news"
882
+ );
883
+ }}
884
+ />, {
885
+ width: 100,
886
+ height: 24,
887
+ });
888
+
889
+ await testSetup.renderOnce();
890
+ await act(async () => {
891
+ testSetup!.mockInput.pressEnter();
892
+ await testSetup!.renderOnce();
893
+ });
894
+
895
+ let frame = testSetup.captureCharFrame();
896
+ expect(frame).toContain("Plugin Login");
897
+ expect(frame).toContain("Username");
898
+ expect(frame).toContain("Password");
899
+
900
+ await act(async () => {
901
+ await testSetup!.mockInput.typeText("vince");
902
+ await testSetup!.renderOnce();
903
+ });
904
+ await act(async () => {
905
+ testSetup!.mockInput.pressTab();
906
+ await testSetup!.renderOnce();
907
+ });
908
+ await act(async () => {
909
+ await testSetup!.mockInput.typeText("secret");
910
+ await testSetup!.renderOnce();
911
+ });
912
+
913
+ frame = await waitForFrameToContain("******");
914
+ expect(frame).toContain("******");
915
+ expect(frame).not.toContain("secret");
916
+ expectSingleBackControl(frame);
917
+
918
+ await act(async () => {
919
+ testSetup!.mockInput.pressEnter();
920
+ await Bun.sleep(0);
921
+ await testSetup!.renderOnce();
922
+ });
923
+
924
+ expect(submittedValues).toEqual({
925
+ username: "vince",
926
+ password: "secret",
927
+ });
928
+ });
929
+
930
+ test("QQ MSFT executes directly without opening a secondary workflow", async () => {
931
+ const created: Array<{ templateId: string; options?: Record<string, unknown> }> = [];
932
+
933
+ testSetup = await testRender(<CommandBarHarness
934
+ query="QQ MSFT"
935
+ selectedTicker="AAPL"
936
+ configurePluginRegistry={(pluginRegistry) => {
937
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
938
+ created.push({ templateId, options });
939
+ };
940
+ }}
941
+ />, {
942
+ width: 100,
943
+ height: 20,
944
+ });
945
+
946
+ await testSetup.renderOnce();
947
+ await act(async () => {
948
+ testSetup!.mockInput.pressEnter();
949
+ await Bun.sleep(0);
950
+ await testSetup!.renderOnce();
951
+ });
952
+
953
+ expect(created).toEqual([{
954
+ templateId: "quote-monitor-pane",
955
+ options: {
956
+ arg: "MSFT",
957
+ symbol: "MSFT",
958
+ ticker: makeTicker("MSFT", "Microsoft Corp."),
959
+ },
960
+ }]);
961
+ });
962
+
963
+ test("CMP AAPL,MSFT creates the comparison chart directly", async () => {
964
+ const created: Array<{ templateId: string; options?: Record<string, unknown> }> = [];
965
+
966
+ testSetup = await testRender(<CommandBarHarness
967
+ query="CMP AAPL,MSFT"
968
+ configurePluginRegistry={(pluginRegistry) => {
969
+ (pluginRegistry.panes as Map<string, any>).set("comparison-chart", {
970
+ id: "comparison-chart",
971
+ name: "Comparison Chart",
972
+ component: () => null,
973
+ defaultPosition: "right",
974
+ });
975
+ (pluginRegistry.paneTemplates as Map<string, any>).set("comparison-chart-pane", {
976
+ id: "comparison-chart-pane",
977
+ paneId: "comparison-chart",
978
+ label: "Comparison Chart",
979
+ description: "Compare multiple symbols in one pane",
980
+ shortcut: { prefix: "CMP", argPlaceholder: "tickers", argKind: "ticker-list" },
981
+ });
982
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
983
+ created.push({ templateId, options });
984
+ };
985
+ }}
986
+ />, {
987
+ width: 100,
988
+ height: 20,
989
+ });
990
+
991
+ await testSetup.renderOnce();
992
+ await act(async () => {
993
+ testSetup!.mockInput.pressEnter();
994
+ await Bun.sleep(0);
995
+ await testSetup!.renderOnce();
996
+ });
997
+
998
+ expect(created).toEqual([{
999
+ templateId: "comparison-chart-pane",
1000
+ options: {
1001
+ arg: "AAPL,MSFT",
1002
+ symbols: ["AAPL", "MSFT"],
1003
+ },
1004
+ }]);
1005
+ });
1006
+
1007
+ test("CMP AAPL, opens inline completion when the ticker list is incomplete", async () => {
1008
+ testSetup = await testRender(<CommandBarHarness
1009
+ query="CMP AAPL,"
1010
+ configurePluginRegistry={(pluginRegistry) => {
1011
+ (pluginRegistry.panes as Map<string, any>).set("comparison-chart", {
1012
+ id: "comparison-chart",
1013
+ name: "Comparison Chart",
1014
+ component: () => null,
1015
+ defaultPosition: "right",
1016
+ });
1017
+ (pluginRegistry.paneTemplates as Map<string, any>).set("comparison-chart-pane", {
1018
+ id: "comparison-chart-pane",
1019
+ paneId: "comparison-chart",
1020
+ label: "Comparison Chart",
1021
+ description: "Compare multiple symbols in one pane",
1022
+ shortcut: { prefix: "CMP", argPlaceholder: "tickers", argKind: "ticker-list" },
1023
+ });
1024
+ }}
1025
+ />, {
1026
+ width: 100,
1027
+ height: 20,
1028
+ });
1029
+
1030
+ await testSetup.renderOnce();
1031
+ await act(async () => {
1032
+ testSetup!.mockInput.pressEnter();
1033
+ await testSetup!.renderOnce();
1034
+ });
1035
+
1036
+ const frame = testSetup.captureCharFrame();
1037
+ expect(frame).toContain("Comparison Chart");
1038
+ expect(frame).toContain("Tickers");
1039
+ expect(frame).toContain("AAPL,");
1040
+ });
1041
+
1042
+ test("AI <prompt> opens the inline workflow and prefills the textarea prompt", async () => {
1043
+ const created: Array<{ templateId: string; options?: Record<string, unknown> }> = [];
1044
+
1045
+ testSetup = await testRender(<CommandBarHarness
1046
+ query="AI quality compounders"
1047
+ configurePluginRegistry={(pluginRegistry) => {
1048
+ (pluginRegistry.panes as Map<string, any>).set("ai-screener", {
1049
+ id: "ai-screener",
1050
+ name: "AI Screener",
1051
+ component: () => null,
1052
+ defaultPosition: "right",
1053
+ defaultMode: "floating",
1054
+ });
1055
+ (pluginRegistry.paneTemplates as Map<string, any>).set("new-ai-screener-pane", {
1056
+ id: "new-ai-screener-pane",
1057
+ paneId: "ai-screener",
1058
+ label: "AI Screener",
1059
+ description: "Create a prompt-driven screener pane.",
1060
+ shortcut: { prefix: "AI", argPlaceholder: "prompt", argKind: "text" },
1061
+ wizard: [
1062
+ {
1063
+ key: "providerId",
1064
+ label: "AI Provider",
1065
+ type: "select",
1066
+ defaultValue: "claude",
1067
+ options: [{ label: "Claude", value: "claude" }],
1068
+ },
1069
+ {
1070
+ key: "prompt",
1071
+ label: "Screener Prompt",
1072
+ type: "textarea",
1073
+ },
1074
+ ],
1075
+ });
1076
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
1077
+ created.push({ templateId, options });
1078
+ };
1079
+ }}
1080
+ />, {
1081
+ width: 100,
1082
+ height: 24,
1083
+ });
1084
+
1085
+ await testSetup.renderOnce();
1086
+ await act(async () => {
1087
+ testSetup!.mockInput.pressEnter();
1088
+ await testSetup!.renderOnce();
1089
+ });
1090
+
1091
+ const frame = testSetup.captureCharFrame();
1092
+ expect(frame).toContain("AI Screener");
1093
+ expect(frame).toContain("AI Provider");
1094
+ expect(frame).toContain("quality compounders");
1095
+ expect(created).toEqual([]);
1096
+ });
1097
+
1098
+ test("submits typed AI screener prompts from the textarea field", async () => {
1099
+ const created: Array<{ templateId: string; options?: Record<string, unknown> }> = [];
1100
+
1101
+ testSetup = await testRender(<CommandBarHarness
1102
+ query="AI"
1103
+ configurePluginRegistry={(pluginRegistry) => {
1104
+ (pluginRegistry.panes as Map<string, any>).set("ai-screener", {
1105
+ id: "ai-screener",
1106
+ name: "AI Screener",
1107
+ component: () => null,
1108
+ defaultPosition: "right",
1109
+ defaultMode: "floating",
1110
+ });
1111
+ (pluginRegistry.paneTemplates as Map<string, any>).set("new-ai-screener-pane", {
1112
+ id: "new-ai-screener-pane",
1113
+ paneId: "ai-screener",
1114
+ label: "AI Screener",
1115
+ description: "Create a prompt-driven screener pane.",
1116
+ shortcut: { prefix: "AI", argPlaceholder: "prompt", argKind: "text" },
1117
+ wizard: [
1118
+ {
1119
+ key: "providerId",
1120
+ label: "AI Provider",
1121
+ type: "select",
1122
+ defaultValue: "claude",
1123
+ options: [{ label: "Claude", value: "claude" }],
1124
+ },
1125
+ {
1126
+ key: "prompt",
1127
+ label: "Screener Prompt",
1128
+ type: "textarea",
1129
+ },
1130
+ ],
1131
+ });
1132
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
1133
+ created.push({ templateId, options });
1134
+ };
1135
+ }}
1136
+ />, {
1137
+ width: 100,
1138
+ height: 24,
1139
+ });
1140
+
1141
+ await testSetup.renderOnce();
1142
+ await act(async () => {
1143
+ testSetup!.mockInput.pressEnter();
1144
+ await testSetup!.renderOnce();
1145
+ });
1146
+ await act(async () => {
1147
+ testSetup!.mockInput.pressTab();
1148
+ await testSetup!.renderOnce();
1149
+ });
1150
+ await act(async () => {
1151
+ await testSetup!.mockInput.typeText("humanoid robot suppliers");
1152
+ await testSetup!.renderOnce();
1153
+ });
1154
+ await clickFrameText("Create Pane");
1155
+ await act(async () => {
1156
+ await Bun.sleep(0);
1157
+ await testSetup!.renderOnce();
1158
+ });
1159
+
1160
+ expect(created).toEqual([{
1161
+ templateId: "new-ai-screener-pane",
1162
+ options: {
1163
+ arg: "humanoid robot suppliers",
1164
+ values: {
1165
+ providerId: "claude",
1166
+ prompt: "humanoid robot suppliers",
1167
+ },
1168
+ },
1169
+ }]);
1170
+ });
1171
+
1172
+ test("edits pane settings inline inside the command bar", async () => {
1173
+ const appliedValues: Array<{ paneId: string; key: string; value: unknown }> = [];
1174
+
1175
+ testSetup = await testRender(<CommandBarHarness
1176
+ query="PS"
1177
+ configureState={(state) => ({
1178
+ ...state,
1179
+ focusedPaneId: "quote-monitor:main",
1180
+ })}
1181
+ hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
1182
+ configurePluginRegistry={(pluginRegistry) => {
1183
+ pluginRegistry.resolvePaneSettings = () => ({
1184
+ paneId: "quote-monitor:main",
1185
+ pane: {
1186
+ instanceId: "quote-monitor:main",
1187
+ paneId: "quote-monitor",
1188
+ title: "Quote Monitor",
1189
+ settings: {},
1190
+ },
1191
+ paneDef: pluginRegistry.panes.get("quote-monitor")!,
1192
+ settingsDef: {
1193
+ title: "Quote Monitor Settings",
1194
+ fields: [{
1195
+ key: "symbol",
1196
+ label: "Symbol",
1197
+ type: "text",
1198
+ description: "Ticker symbol to track",
1199
+ }],
1200
+ },
1201
+ context: {
1202
+ config: createDefaultConfig("/tmp/gloomberb-test"),
1203
+ layout: cloneLayout(createDefaultConfig("/tmp/gloomberb-test").layout),
1204
+ paneId: "quote-monitor:main",
1205
+ paneType: "quote-monitor",
1206
+ pane: {
1207
+ instanceId: "quote-monitor:main",
1208
+ paneId: "quote-monitor",
1209
+ title: "Quote Monitor",
1210
+ settings: {},
1211
+ },
1212
+ settings: {},
1213
+ paneState: {},
1214
+ activeTicker: "AAPL",
1215
+ activeCollectionId: "main",
1216
+ },
1217
+ }) as any;
1218
+ pluginRegistry.applyPaneSettingValueFn = async (paneId, field, value) => {
1219
+ appliedValues.push({ paneId, key: field.key, value });
1220
+ };
1221
+ }}
1222
+ />, {
1223
+ width: 100,
1224
+ height: 20,
1225
+ });
1226
+
1227
+ await testSetup.renderOnce();
1228
+
1229
+ await act(async () => {
1230
+ testSetup!.mockInput.pressEnter();
1231
+ await testSetup!.renderOnce();
1232
+ });
1233
+ let frame = testSetup.captureCharFrame();
1234
+ expect(frame).toContain("Quote Monitor Settings");
1235
+ expect(frame).toContain("Symbol");
1236
+
1237
+ await act(async () => {
1238
+ testSetup!.mockInput.pressEnter();
1239
+ await testSetup!.renderOnce();
1240
+ });
1241
+ frame = testSetup.captureCharFrame();
1242
+ await clickFrameText("Symbol");
1243
+ frame = testSetup.captureCharFrame();
1244
+ expect(frame).toContain("Apply");
1245
+ expect(frame).toContain("Symbol");
1246
+
1247
+ await act(async () => {
1248
+ await testSetup!.mockInput.typeText("MSFT");
1249
+ await testSetup!.renderOnce();
1250
+ });
1251
+ await act(async () => {
1252
+ testSetup!.mockInput.pressEnter();
1253
+ await Bun.sleep(0);
1254
+ await testSetup!.renderOnce();
1255
+ });
1256
+
1257
+ expect(appliedValues).toEqual([{
1258
+ paneId: "quote-monitor:main",
1259
+ key: "symbol",
1260
+ value: "MSFT",
1261
+ }]);
1262
+ });
1263
+
1264
+ test("renders pane-setting multi-select pickers only once inside the command bar", async () => {
1265
+ testSetup = await testRender(<CommandBarHarness
1266
+ query="PS"
1267
+ configureState={(state) => ({
1268
+ ...state,
1269
+ focusedPaneId: "quote-monitor:main",
1270
+ })}
1271
+ hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
1272
+ configurePluginRegistry={(pluginRegistry) => {
1273
+ pluginRegistry.resolvePaneSettings = () => ({
1274
+ paneId: "quote-monitor:main",
1275
+ pane: {
1276
+ instanceId: "quote-monitor:main",
1277
+ paneId: "quote-monitor",
1278
+ title: "Quote Monitor",
1279
+ settings: {},
1280
+ },
1281
+ paneDef: pluginRegistry.panes.get("quote-monitor")!,
1282
+ settingsDef: {
1283
+ title: "Quote Monitor Settings",
1284
+ fields: [{
1285
+ key: "columns",
1286
+ label: "Columns",
1287
+ type: "ordered-multi-select",
1288
+ description: "Visible columns",
1289
+ options: [
1290
+ { value: "volume", label: "AAA", description: "Volume column" },
1291
+ { value: "spread", label: "BBB", description: "Spread column" },
1292
+ { value: "beta", label: "CCC", description: "Beta column" },
1293
+ ],
1294
+ }],
1295
+ },
1296
+ context: {
1297
+ config: createDefaultConfig("/tmp/gloomberb-test"),
1298
+ layout: cloneLayout(createDefaultConfig("/tmp/gloomberb-test").layout),
1299
+ paneId: "quote-monitor:main",
1300
+ paneType: "quote-monitor",
1301
+ pane: {
1302
+ instanceId: "quote-monitor:main",
1303
+ paneId: "quote-monitor",
1304
+ title: "Quote Monitor",
1305
+ settings: {},
1306
+ },
1307
+ settings: {
1308
+ columns: ["volume", "spread"],
1309
+ },
1310
+ paneState: {},
1311
+ activeTicker: "AAPL",
1312
+ activeCollectionId: "main",
1313
+ },
1314
+ }) as any;
1315
+ }}
1316
+ />, {
1317
+ width: 100,
1318
+ height: 20,
1319
+ });
1320
+
1321
+ await testSetup.renderOnce();
1322
+
1323
+ await act(async () => {
1324
+ testSetup!.mockInput.pressEnter();
1325
+ await testSetup!.renderOnce();
1326
+ });
1327
+ await clickFrameText("Columns");
1328
+
1329
+ const frame = await waitForFrameToContain("Done");
1330
+ expect(frame).toContain("Columns");
1331
+ expect(frame).toContain("Done");
1332
+ expect(frame).not.toContain("Options");
1333
+ expectSingleBackControl(frame);
1334
+ });
1335
+
1336
+ test("matches the IBKR trading shortcut when a gateway profile exists", async () => {
1337
+ testSetup = await testRender(<CommandBarHarness
1338
+ query="IBKR"
1339
+ configureConfig={(config) => ({
1340
+ ...config,
1341
+ brokerInstances: [{
1342
+ id: "ibkr-paper",
1343
+ brokerType: "ibkr",
1344
+ label: "Paper",
1345
+ connectionMode: "gateway",
1346
+ config: { connectionMode: "gateway", gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } },
1347
+ enabled: true,
1348
+ }],
1349
+ })}
1350
+ />, {
1351
+ width: 100,
1352
+ height: 18,
1353
+ });
1354
+
1355
+ await testSetup.renderOnce();
1356
+
1357
+ const frame = testSetup.captureCharFrame();
1358
+ expect(frame).toContain("New IBKR Trading Pane");
1359
+ expect(frame).toContain("IBKR");
1360
+ });
1361
+
1362
+ test("matches the direct help command", async () => {
1363
+ testSetup = await testRender(<CommandBarHarness query="help" />, {
1364
+ width: 80,
1365
+ height: 18,
1366
+ });
1367
+
1368
+ await testSetup.renderOnce();
1369
+
1370
+ const frame = testSetup.captureCharFrame();
1371
+ expect(frame).toContain("Help");
1372
+ expect(frame).toContain("Navigation");
1373
+ });
1374
+
1375
+ test("skips pane templates whose canCreate throws", async () => {
1376
+ testSetup = await testRender(<CommandBarHarness
1377
+ query=""
1378
+ selectedTicker="AAPL"
1379
+ configurePluginRegistry={(pluginRegistry) => {
1380
+ (pluginRegistry.paneTemplates as Map<string, any>).set("broken-pane", {
1381
+ id: "broken-pane",
1382
+ paneId: "chat",
1383
+ label: "Broken Pane",
1384
+ description: "Should not crash the command bar",
1385
+ shortcut: { prefix: "IBKR" },
1386
+ canCreate: () => {
1387
+ throw new ReferenceError("getIbkrInstances is not defined");
1388
+ },
1389
+ });
1390
+ }}
1391
+ />, {
1392
+ width: 100,
1393
+ height: 24,
1394
+ });
1395
+
1396
+ await testSetup.renderOnce();
1397
+
1398
+ const frame = testSetup.captureCharFrame();
1399
+ expect(frame).toContain("Quote Monitor");
1400
+ expect(frame).not.toContain("Broken Pane");
1401
+ });
1402
+
1403
+ test("groups ticker search sections and keeps saved matches above looser provider results", async () => {
1404
+ testSetup = await testRender(
1405
+ <CommandBarHarness
1406
+ query="T appl"
1407
+ dataProvider={makeDataProvider(async () => [
1408
+ { providerId: "yahoo", symbol: "IVSX", name: "Invsivx Holdings", exchange: "NYSE", type: "ETF" },
1409
+ { providerId: "yahoo", symbol: "AAPL", name: "Apple Inc", exchange: "NASDAQ", type: "EQUITY" },
1410
+ { providerId: "yahoo", symbol: "AMAT", name: "Applied Materials", exchange: "NASDAQ", type: "EQUITY" },
1411
+ { providerId: "yahoo", symbol: "AAOI", name: "Applied Optoelectronics", exchange: "NASDAQ", type: "EQUITY" },
1412
+ { providerId: "yahoo", symbol: "APP", name: "AppLovin Corp", exchange: "NASDAQ", type: "EQUITY" },
1413
+ ])}
1414
+ />,
1415
+ { width: 80, height: 24 },
1416
+ );
1417
+
1418
+ await testSetup.renderOnce();
1419
+ await waitForFrameToContain("AAOI");
1420
+
1421
+ const frame = testSetup.captureCharFrame();
1422
+ const rows = frame.split("\n");
1423
+ const savedHeadings = frame.split("\n").filter((line) => line.trim() === "Saved");
1424
+ const otherListingsHeadings = frame.split("\n").filter((line) => line.trim() === "Other Listings");
1425
+ const aaplRow = rows.findIndex((line) => line.trimStart().startsWith("AAPL") && line.includes("NASDAQ"));
1426
+ const appRow = rows.findIndex((line) => line.trimStart().startsWith("APP") && line.includes("NASDAQ"));
1427
+ expect(savedHeadings).toHaveLength(1);
1428
+ expect(otherListingsHeadings).toHaveLength(1);
1429
+ expect(aaplRow).toBeGreaterThanOrEqual(0);
1430
+ expect(appRow).toBeGreaterThanOrEqual(0);
1431
+ expect(aaplRow).toBeLessThan(appRow);
1432
+ expect(frame).toMatchSnapshot();
1433
+ });
1434
+
1435
+ test("renders form-layout wizard fields together on one screen", async () => {
1436
+ testSetup = await testRender(
1437
+ <CommandBarHarness
1438
+ query="auth login"
1439
+ live
1440
+ configurePluginRegistry={(pluginRegistry) => {
1441
+ pluginRegistry.commands.set("auth-login", {
1442
+ id: "auth-login",
1443
+ label: "Auth Login",
1444
+ description: "Log in to your account",
1445
+ keywords: ["login", "auth"],
1446
+ category: "config",
1447
+ wizardLayout: "form",
1448
+ wizard: [
1449
+ { key: "email", label: "Email", type: "text", placeholder: "email@example.com" },
1450
+ { key: "password", label: "Password", type: "password", placeholder: "Your password" },
1451
+ ],
1452
+ execute: async () => {},
1453
+ } as any);
1454
+ }}
1455
+ />,
1456
+ { width: 80, height: 24 },
1457
+ );
1458
+
1459
+ await testSetup.renderOnce();
1460
+ await clickFrameText("Auth Login");
1461
+ await act(async () => {
1462
+ await testSetup!.renderOnce();
1463
+ });
1464
+
1465
+ let frame = testSetup.captureCharFrame();
1466
+ expect(frame).toContain("Back");
1467
+ expect(frame).toContain("Email");
1468
+ expect(frame).toContain("Password");
1469
+ expect(frame).toContain("Your password");
1470
+ expectSingleBackControl(frame);
1471
+ });
1472
+
1473
+ test("submits single-field form-layout wizards", async () => {
1474
+ const submitted: Array<Record<string, string> | undefined> = [];
1475
+
1476
+ testSetup = await testRender(
1477
+ <CommandBarHarness
1478
+ query="workspace"
1479
+ live
1480
+ configurePluginRegistry={(pluginRegistry) => {
1481
+ pluginRegistry.commands.set("new-workspace", {
1482
+ id: "new-workspace",
1483
+ label: "Workspace",
1484
+ description: "Create a workspace",
1485
+ keywords: ["workspace"],
1486
+ category: "config",
1487
+ wizardLayout: "form",
1488
+ wizard: [
1489
+ { key: "name", label: "Name", type: "text", placeholder: "Research" },
1490
+ ],
1491
+ execute: async (values) => {
1492
+ submitted.push(values);
1493
+ },
1494
+ } as any);
1495
+ }}
1496
+ />,
1497
+ { width: 80, height: 24 },
1498
+ );
1499
+
1500
+ await testSetup.renderOnce();
1501
+
1502
+ await act(async () => {
1503
+ testSetup!.mockInput.pressEnter();
1504
+ await Bun.sleep(0);
1505
+ await testSetup!.renderOnce();
1506
+ await testSetup!.renderOnce();
1507
+ });
1508
+
1509
+ await act(async () => {
1510
+ await testSetup!.mockInput.typeText("Research");
1511
+ await testSetup!.renderOnce();
1512
+ await testSetup!.renderOnce();
1513
+ });
1514
+
1515
+ await act(async () => {
1516
+ testSetup!.mockInput.pressEnter();
1517
+ await Bun.sleep(0);
1518
+ await testSetup!.renderOnce();
1519
+ await testSetup!.renderOnce();
1520
+ });
1521
+
1522
+ expect(submitted).toEqual([{ name: "Research" }]);
1523
+ });
1524
+ });