gloomberb 0.7.2 → 0.7.3

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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -9,7 +9,8 @@ import { cloneLayout, createDefaultConfig, type AppConfig } from "../../types/co
9
9
  import type { DataProvider } from "../../types/data-provider";
10
10
  import type { TickerRecord } from "../../types/ticker";
11
11
  import type { PluginRegistry } from "../../plugins/registry";
12
- import type { PaneTemplateCreateOptions } from "../../types/plugin";
12
+ import type { PaneSettingField, PaneTemplateCreateOptions } from "../../types/plugin";
13
+ import { useShortcut } from "../../react/input";
13
14
 
14
15
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
15
16
 
@@ -169,8 +170,9 @@ function makePluginRegistry(hasPaneSettings: (paneId: string) => boolean = () =>
169
170
  id: "quote-monitor-pane",
170
171
  paneId: "quote-monitor",
171
172
  label: "Quote Monitor",
172
- description: "Open a compact quote monitor for the selected ticker",
173
- shortcut: { prefix: "QQ", argPlaceholder: "ticker" },
173
+ description: "Open a compact quote monitor for one or more tickers",
174
+ shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" },
175
+ wizard: [{ key: "tickers", label: "Quote Tickers", type: "text" }],
174
176
  }],
175
177
  ["new-ibkr-trading-pane", {
176
178
  id: "new-ibkr-trading-pane",
@@ -232,10 +234,53 @@ function makePluginRegistry(hasPaneSettings: (paneId: string) => boolean = () =>
232
234
  } as unknown as PluginRegistry;
233
235
  }
234
236
 
237
+ function makeQuoteMonitorPaneSettingsDescriptor(
238
+ pluginRegistry: PluginRegistry,
239
+ fields: PaneSettingField[],
240
+ settings: Record<string, unknown> = {},
241
+ ) {
242
+ const config = createDefaultConfig("/tmp/gloomberb-test");
243
+ const pane = {
244
+ instanceId: "quote-monitor:main",
245
+ paneId: "quote-monitor",
246
+ title: "Quote Monitor",
247
+ settings,
248
+ };
249
+ return {
250
+ paneId: "quote-monitor:main",
251
+ pane,
252
+ paneDef: pluginRegistry.panes.get("quote-monitor")!,
253
+ settingsDef: {
254
+ title: "Quote Monitor Settings",
255
+ fields,
256
+ },
257
+ context: {
258
+ config,
259
+ layout: cloneLayout(config.layout),
260
+ paneId: "quote-monitor:main",
261
+ paneType: "quote-monitor",
262
+ pane,
263
+ settings,
264
+ paneState: {},
265
+ activeTicker: "AAPL",
266
+ activeCollectionId: "main",
267
+ },
268
+ } as any;
269
+ }
270
+
235
271
  function ThemeProbe() {
236
272
  return <text>{`theme:${useThemeId()}`}</text>;
237
273
  }
238
274
 
275
+ function UnhandledEnterProbe({ onEnter }: { onEnter: () => void }) {
276
+ useShortcut((event) => {
277
+ if (event.name === "enter" || event.name === "return") {
278
+ onEnter();
279
+ }
280
+ });
281
+ return null;
282
+ }
283
+
239
284
  function CommandBarHarness({
240
285
  query,
241
286
  disabledPlugins = [],
@@ -251,6 +296,7 @@ function CommandBarHarness({
251
296
  hasPaneSettings,
252
297
  onCheckForUpdates,
253
298
  onAction,
299
+ onUnhandledEnter,
254
300
  }: {
255
301
  query: string;
256
302
  disabledPlugins?: string[];
@@ -266,6 +312,7 @@ function CommandBarHarness({
266
312
  hasPaneSettings?: (paneId: string) => boolean;
267
313
  onCheckForUpdates?: () => void | Promise<void>;
268
314
  onAction?: (action: AppAction) => void;
315
+ onUnhandledEnter?: () => void;
269
316
  }) {
270
317
  let config = {
271
318
  ...createDefaultConfig("/tmp/gloomberb-test"),
@@ -330,6 +377,7 @@ function CommandBarHarness({
330
377
  onCheckForUpdates={onCheckForUpdates}
331
378
  />
332
379
  )}
380
+ {onUnhandledEnter && <UnhandledEnterProbe onEnter={onUnhandledEnter} />}
333
381
  </TestDialogProvider>
334
382
  </AppContext>
335
383
  </ThemeProvider>
@@ -966,7 +1014,7 @@ describe("CommandBar", () => {
966
1014
  expect(frame).not.toContain("Back");
967
1015
  });
968
1016
 
969
- test("QQ without an active ticker opens inline ticker search on enter", async () => {
1017
+ test("QQ without an active ticker opens inline ticker-list entry on enter", async () => {
970
1018
  testSetup = await testRender(<CommandBarHarness query="QQ" />, {
971
1019
  width: 100,
972
1020
  height: 20,
@@ -981,6 +1029,25 @@ describe("CommandBar", () => {
981
1029
  await testSetup!.renderOnce();
982
1030
  });
983
1031
 
1032
+ const frame = testSetup.captureCharFrame();
1033
+ expect(frame).toContain("Back");
1034
+ expect(frame).toContain("Quote Tickers");
1035
+ });
1036
+
1037
+ test("T without an active ticker opens ticker search on enter", async () => {
1038
+ testSetup = await testRender(<CommandBarHarness query="T" />, {
1039
+ width: 100,
1040
+ height: 20,
1041
+ });
1042
+
1043
+ await testSetup.renderOnce();
1044
+ expect(testSetup.captureCharFrame()).toContain("Description");
1045
+
1046
+ await act(async () => {
1047
+ testSetup!.mockInput.pressEnter();
1048
+ await testSetup!.renderOnce();
1049
+ });
1050
+
984
1051
  const frame = testSetup.captureCharFrame();
985
1052
  expect(frame).toContain("Back");
986
1053
  expect(frame).toContain("Security Description");
@@ -1036,12 +1103,43 @@ describe("CommandBar", () => {
1036
1103
  templateId: "quote-monitor-pane",
1037
1104
  options: {
1038
1105
  arg: "AAPL",
1039
- symbol: "AAPL",
1040
- ticker: makeTicker("AAPL", "Apple Inc."),
1106
+ symbols: ["AAPL"],
1041
1107
  },
1042
1108
  }]);
1043
1109
  });
1044
1110
 
1111
+ test("consumes enter before focused pane shortcuts when executing a pane shortcut", async () => {
1112
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
1113
+ let leakedEnterCount = 0;
1114
+
1115
+ testSetup = await testRender(<CommandBarHarness
1116
+ query="PF"
1117
+ live
1118
+ onUnhandledEnter={() => {
1119
+ leakedEnterCount += 1;
1120
+ }}
1121
+ configurePluginRegistry={(pluginRegistry) => {
1122
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
1123
+ created.push({ templateId, options });
1124
+ };
1125
+ }}
1126
+ />, {
1127
+ width: 100,
1128
+ height: 20,
1129
+ });
1130
+
1131
+ await testSetup.renderOnce();
1132
+
1133
+ await act(async () => {
1134
+ testSetup!.mockInput.pressEnter();
1135
+ await Bun.sleep(0);
1136
+ await testSetup!.renderOnce();
1137
+ });
1138
+
1139
+ expect(created).toEqual([{ templateId: "new-portfolio-pane", options: undefined }]);
1140
+ expect(leakedEnterCount).toBe(0);
1141
+ });
1142
+
1045
1143
  test("typing a chat channel shortcut opens that channel directly", async () => {
1046
1144
  const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
1047
1145
 
@@ -1134,6 +1232,121 @@ describe("CommandBar", () => {
1134
1232
  expect(frame).toContain("AAOI");
1135
1233
  });
1136
1234
 
1235
+ test("keeps exact T ticker matches above noisy provider matches", async () => {
1236
+ testSetup = await testRender(
1237
+ <CommandBarHarness
1238
+ query="T AMD"
1239
+ extraTickers={[
1240
+ makeTicker("AMDL", "GraniteShares 2x Long AMD Daily"),
1241
+ makeTicker("AMUU", "Direxion Daily AMD Bull 2X ETF"),
1242
+ ]}
1243
+ dataProvider={makeDataProvider(async () => [
1244
+ { providerId: "yahoo", symbol: "DES", name: "AMD-themed synthetic listing", exchange: "ASX", type: "EQUITY" },
1245
+ { providerId: "yahoo", symbol: "DES2", name: "AMD-themed secondary listing", exchange: "LSE", type: "EQUITY" },
1246
+ { providerId: "yahoo", symbol: "AMD", name: "Advanced Micro Devices", exchange: "NASDAQ", type: "EQUITY" },
1247
+ ])}
1248
+ />,
1249
+ { width: 100, height: 24 },
1250
+ );
1251
+
1252
+ await testSetup.renderOnce();
1253
+ const frame = await waitForFrameToContain("DES2");
1254
+ const resultLines = frame
1255
+ .split("\n")
1256
+ .filter((line) => /\b(AMD|DES2?|DES)\b/.test(line) && /\b(NASDAQ|ASX|LSE)\b/.test(line));
1257
+
1258
+ expect(resultLines[0]).toContain("AMD");
1259
+ expect(resultLines[0]).toContain("NASDAQ");
1260
+ });
1261
+
1262
+ test("loads exact provider matches for plain ticker-shaped root queries", async () => {
1263
+ testSetup = await testRender(
1264
+ <CommandBarHarness
1265
+ query="amd"
1266
+ extraTickers={[
1267
+ makeTicker("AMDL", "GraniteShares 2x Long AMD Daily"),
1268
+ makeTicker("AMUU", "Direxion Daily AMD Bull 2X ETF"),
1269
+ ]}
1270
+ dataProvider={makeDataProvider(async () => [
1271
+ { providerId: "yahoo", symbol: "DES", name: "AMD-themed synthetic listing", exchange: "ASX", type: "EQUITY" },
1272
+ { providerId: "yahoo", symbol: "AMD", name: "Advanced Micro Devices", exchange: "NASDAQ", type: "EQUITY" },
1273
+ ])}
1274
+ />,
1275
+ { width: 100, height: 24 },
1276
+ );
1277
+
1278
+ await testSetup.renderOnce();
1279
+ const frame = await waitForFrameToContain("DES");
1280
+ const resultLines = frame
1281
+ .split("\n")
1282
+ .filter((line) => /\b(AMD|AMDL|AMUU|DES)\b/.test(line) && /\b(NASDAQ|ASX)\b/.test(line));
1283
+
1284
+ expect(resultLines[0]).toContain("AMD");
1285
+ expect(resultLines[0]).toContain("NASDAQ");
1286
+ });
1287
+
1288
+ test("keeps pane matches visible alongside plain ticker provider results", async () => {
1289
+ testSetup = await testRender(
1290
+ <CommandBarHarness
1291
+ query="opti"
1292
+ dataProvider={makeDataProvider(async () => [
1293
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "CSE", type: "EQUITY" },
1294
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "NEO", type: "EQUITY" },
1295
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "LSE", type: "EQUITY" },
1296
+ ])}
1297
+ configurePluginRegistry={(pluginRegistry) => {
1298
+ const paneTemplates = pluginRegistry.paneTemplates as Map<string, any>;
1299
+ paneTemplates.set("options-pane", {
1300
+ id: "options-pane",
1301
+ paneId: "options",
1302
+ label: "Options",
1303
+ description: "Options chain for the selected ticker",
1304
+ shortcut: { prefix: "OMON", argPlaceholder: "ticker", argKind: "ticker" },
1305
+ canCreate: (context: any, options?: PaneTemplateCreateOptions) => (
1306
+ context.activeTicker !== null || !!options?.arg
1307
+ ),
1308
+ });
1309
+ }}
1310
+ />,
1311
+ { width: 100, height: 24 },
1312
+ );
1313
+
1314
+ await testSetup.renderOnce();
1315
+ const frame = await waitForFrameToContain("OPTI");
1316
+ expect(frame).toContain("Options");
1317
+ expect(frame).toContain("OMON");
1318
+ });
1319
+
1320
+ test("clears stale provider rows after a plain ticker root search is cleared", async () => {
1321
+ testSetup = await testRender(
1322
+ <CommandBarHarness
1323
+ query=""
1324
+ live
1325
+ dataProvider={makeDataProvider(async () => [
1326
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "CSE", type: "EQUITY" },
1327
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "NEO", type: "EQUITY" },
1328
+ { providerId: "yahoo", symbol: "OPTI", name: "Optimi Health Corp", exchange: "LSE", type: "EQUITY" },
1329
+ ])}
1330
+ />,
1331
+ { width: 100, height: 24 },
1332
+ );
1333
+
1334
+ await testSetup.renderOnce();
1335
+ await act(async () => {
1336
+ await testSetup!.mockInput.typeText("opti");
1337
+ await testSetup!.renderOnce();
1338
+ });
1339
+
1340
+ await waitForFrameToContain("OPTI");
1341
+ await emitKeypress(testSetup, { name: "u", ctrl: true });
1342
+ await renderFrames(2);
1343
+
1344
+ const frame = testSetup.captureCharFrame();
1345
+ expect(frame).toContain("Search");
1346
+ expect(frame).toContain("Tickers");
1347
+ expect(frame).not.toContain("OPTI");
1348
+ });
1349
+
1137
1350
  test("DES MSFT opens an exact ticker directly", async () => {
1138
1351
  const pinned: string[] = [];
1139
1352
 
@@ -1160,6 +1373,33 @@ describe("CommandBar", () => {
1160
1373
  expect(pinned).toEqual(["MSFT"]);
1161
1374
  });
1162
1375
 
1376
+ test("T AMD opens an exact ticker directly", async () => {
1377
+ const pinned: string[] = [];
1378
+
1379
+ testSetup = await testRender(
1380
+ <CommandBarHarness
1381
+ query="T AMD"
1382
+ extraTickers={[makeTicker("AMD", "Advanced Micro Devices")]}
1383
+ configurePluginRegistry={(pluginRegistry) => {
1384
+ pluginRegistry.pinTicker = (symbol) => {
1385
+ pinned.push(symbol);
1386
+ };
1387
+ }}
1388
+ />,
1389
+ { width: 100, height: 20 },
1390
+ );
1391
+
1392
+ await testSetup.renderOnce();
1393
+ await act(async () => {
1394
+ testSetup!.mockInput.pressEnter();
1395
+ await Bun.sleep(0);
1396
+ await testSetup!.renderOnce();
1397
+ await testSetup!.renderOnce();
1398
+ });
1399
+
1400
+ expect(pinned).toEqual(["AMD"]);
1401
+ });
1402
+
1163
1403
  test("AW AAPL uses the active watchlist target by default", async () => {
1164
1404
  const saved: TickerRecord[] = [];
1165
1405
 
@@ -1897,6 +2137,32 @@ describe("CommandBar", () => {
1897
2137
  expect(frame).toContain("Current Layout");
1898
2138
  });
1899
2139
 
2140
+ test("runs layout actions directly from root search", async () => {
2141
+ const actions: AppAction[] = [];
2142
+
2143
+ testSetup = await testRender(<CommandBarHarness
2144
+ query="undo layout change"
2145
+ live
2146
+ configureConfig={layoutModeConfig}
2147
+ configureState={layoutModeState}
2148
+ onAction={(action) => actions.push(action)}
2149
+ />, {
2150
+ width: 90,
2151
+ height: 24,
2152
+ });
2153
+
2154
+ await testSetup.renderOnce();
2155
+
2156
+ expect(testSetup.captureCharFrame()).toContain("Undo Layout Change");
2157
+
2158
+ await act(async () => {
2159
+ testSetup!.mockInput.pressEnter();
2160
+ await testSetup!.renderOnce();
2161
+ });
2162
+
2163
+ expect(actions.some((action) => action.type === "UNDO_LAYOUT")).toBe(true);
2164
+ });
2165
+
1900
2166
  test("renders filtered saved layouts with textual previews", async () => {
1901
2167
  testSetup = await testRender(<CommandBarHarness
1902
2168
  query="LAY Research"
@@ -2028,6 +2294,66 @@ describe("CommandBar", () => {
2028
2294
  expect(created).toEqual([{ templateId: "news-top-pane", options: undefined }]);
2029
2295
  });
2030
2296
 
2297
+ test("surfaces ticker-required pane templates by name without an active ticker", async () => {
2298
+ testSetup = await testRender(<CommandBarHarness
2299
+ query="Options"
2300
+ extraTickers={[makeTicker("^RUT", "Chicago Board Options Exchange Russell 2000")]}
2301
+ configurePluginRegistry={(pluginRegistry) => {
2302
+ const paneTemplates = pluginRegistry.paneTemplates as Map<string, any>;
2303
+ paneTemplates.set("options-pane", {
2304
+ id: "options-pane",
2305
+ paneId: "options",
2306
+ label: "Options",
2307
+ description: "Options chain for the selected ticker",
2308
+ keywords: ["options", "chain", "calls", "puts"],
2309
+ shortcut: { prefix: "OMON", argPlaceholder: "ticker", argKind: "ticker" },
2310
+ canCreate: (context: any, options?: PaneTemplateCreateOptions) => (
2311
+ context.activeTicker !== null || !!options?.arg
2312
+ ),
2313
+ });
2314
+ }}
2315
+ />, {
2316
+ width: 100,
2317
+ height: 18,
2318
+ });
2319
+
2320
+ await testSetup.renderOnce();
2321
+
2322
+ let frame = testSetup.captureCharFrame();
2323
+ expect(frame).toContain("Options");
2324
+ expect(frame).toContain("OMON");
2325
+
2326
+ await act(async () => {
2327
+ testSetup!.mockInput.pressEnter();
2328
+ await testSetup!.renderOnce();
2329
+ });
2330
+
2331
+ frame = testSetup.captureCharFrame();
2332
+ expect(frame).toContain("Back");
2333
+ expect(frame).toContain("Ticker");
2334
+ });
2335
+
2336
+ test("ranks exact ticker symbols before longer ticker prefixes in root search", async () => {
2337
+ testSetup = await testRender(<CommandBarHarness
2338
+ query="amd"
2339
+ extraTickers={[
2340
+ makeTicker("AMDL", "GraniteShares 2x Long AMD Daily ETF"),
2341
+ makeTicker("AMD", "Advanced Micro Devices"),
2342
+ ]}
2343
+ />, {
2344
+ width: 100,
2345
+ height: 18,
2346
+ });
2347
+
2348
+ await testSetup.renderOnce();
2349
+
2350
+ const tickerLines = testSetup.captureCharFrame()
2351
+ .split("\n")
2352
+ .filter((line) => /\bAMD\b|\bAMDL\b/.test(line));
2353
+ expect(tickerLines[0]).toMatch(/\bAMD\b/);
2354
+ expect(tickerLines[0]).not.toMatch(/\bAMDL\b/);
2355
+ });
2356
+
2031
2357
  test("opens plugin command wizards inline inside the command bar", async () => {
2032
2358
  let submittedValues: Record<string, string> | undefined;
2033
2359
 
@@ -2199,8 +2525,39 @@ describe("CommandBar", () => {
2199
2525
  templateId: "quote-monitor-pane",
2200
2526
  options: {
2201
2527
  arg: "MSFT",
2202
- symbol: "MSFT",
2203
- ticker: makeTicker("MSFT", "Microsoft Corp."),
2528
+ symbols: ["MSFT"],
2529
+ },
2530
+ }]);
2531
+ });
2532
+
2533
+ test("QQ AAPL,MSFT creates a multi-symbol quote monitor directly", async () => {
2534
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
2535
+
2536
+ testSetup = await testRender(<CommandBarHarness
2537
+ query="QQ AAPL,MSFT"
2538
+ selectedTicker="AAPL"
2539
+ configurePluginRegistry={(pluginRegistry) => {
2540
+ pluginRegistry.createPaneFromTemplateAsyncFn = async (templateId, options) => {
2541
+ created.push({ templateId, options });
2542
+ };
2543
+ }}
2544
+ />, {
2545
+ width: 100,
2546
+ height: 20,
2547
+ });
2548
+
2549
+ await testSetup.renderOnce();
2550
+ await act(async () => {
2551
+ testSetup!.mockInput.pressEnter();
2552
+ await Bun.sleep(0);
2553
+ await testSetup!.renderOnce();
2554
+ });
2555
+
2556
+ expect(created).toEqual([{
2557
+ templateId: "quote-monitor-pane",
2558
+ options: {
2559
+ arg: "AAPL,MSFT",
2560
+ symbols: ["AAPL", "MSFT"],
2204
2561
  },
2205
2562
  }]);
2206
2563
  });
@@ -2469,41 +2826,12 @@ describe("CommandBar", () => {
2469
2826
  })}
2470
2827
  hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
2471
2828
  configurePluginRegistry={(pluginRegistry) => {
2472
- pluginRegistry.resolvePaneSettings = () => ({
2473
- paneId: "quote-monitor:main",
2474
- pane: {
2475
- instanceId: "quote-monitor:main",
2476
- paneId: "quote-monitor",
2477
- title: "Quote Monitor",
2478
- settings: {},
2479
- },
2480
- paneDef: pluginRegistry.panes.get("quote-monitor")!,
2481
- settingsDef: {
2482
- title: "Quote Monitor Settings",
2483
- fields: [{
2484
- key: "symbol",
2485
- label: "Symbol",
2486
- type: "text",
2487
- description: "Ticker symbol to track",
2488
- }],
2489
- },
2490
- context: {
2491
- config: createDefaultConfig("/tmp/gloomberb-test"),
2492
- layout: cloneLayout(createDefaultConfig("/tmp/gloomberb-test").layout),
2493
- paneId: "quote-monitor:main",
2494
- paneType: "quote-monitor",
2495
- pane: {
2496
- instanceId: "quote-monitor:main",
2497
- paneId: "quote-monitor",
2498
- title: "Quote Monitor",
2499
- settings: {},
2500
- },
2501
- settings: {},
2502
- paneState: {},
2503
- activeTicker: "AAPL",
2504
- activeCollectionId: "main",
2505
- },
2506
- }) as any;
2829
+ pluginRegistry.resolvePaneSettings = () => makeQuoteMonitorPaneSettingsDescriptor(pluginRegistry, [{
2830
+ key: "symbol",
2831
+ label: "Symbol",
2832
+ type: "text",
2833
+ description: "Ticker symbol to track",
2834
+ }]);
2507
2835
  pluginRegistry.applyPaneSettingValueFn = async (paneId, field, value) => {
2508
2836
  appliedValues.push({ paneId, key: field.key, value });
2509
2837
  };
@@ -2550,6 +2878,64 @@ describe("CommandBar", () => {
2550
2878
  }]);
2551
2879
  });
2552
2880
 
2881
+ test("opens focused pane settings directly from root search", async () => {
2882
+ const appliedValues: Array<{ paneId: string; key: string; value: unknown }> = [];
2883
+
2884
+ testSetup = await testRender(<CommandBarHarness
2885
+ query="ticker symbol"
2886
+ configureState={(state) => ({
2887
+ ...state,
2888
+ focusedPaneId: "quote-monitor:main",
2889
+ })}
2890
+ hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
2891
+ configurePluginRegistry={(pluginRegistry) => {
2892
+ pluginRegistry.resolvePaneSettings = () => makeQuoteMonitorPaneSettingsDescriptor(pluginRegistry, [{
2893
+ key: "symbol",
2894
+ label: "Symbol",
2895
+ type: "text",
2896
+ description: "Ticker symbol to track",
2897
+ }]);
2898
+ pluginRegistry.applyPaneSettingValueFn = async (paneId, field, value) => {
2899
+ appliedValues.push({ paneId, key: field.key, value });
2900
+ };
2901
+ }}
2902
+ />, {
2903
+ width: 100,
2904
+ height: 20,
2905
+ });
2906
+
2907
+ await testSetup.renderOnce();
2908
+
2909
+ let frame = testSetup.captureCharFrame();
2910
+ expect(frame).toContain("Quote Monitor Settings");
2911
+ expect(frame).toContain("Symbol");
2912
+
2913
+ await act(async () => {
2914
+ testSetup!.mockInput.pressEnter();
2915
+ await testSetup!.renderOnce();
2916
+ });
2917
+
2918
+ frame = testSetup.captureCharFrame();
2919
+ expect(frame).toContain("Apply");
2920
+ expect(frame).toContain("Symbol");
2921
+
2922
+ await act(async () => {
2923
+ await testSetup!.mockInput.typeText("MSFT");
2924
+ await testSetup!.renderOnce();
2925
+ });
2926
+ await act(async () => {
2927
+ testSetup!.mockInput.pressEnter();
2928
+ await Bun.sleep(0);
2929
+ await testSetup!.renderOnce();
2930
+ });
2931
+
2932
+ expect(appliedValues).toEqual([{
2933
+ paneId: "quote-monitor:main",
2934
+ key: "symbol",
2935
+ value: "MSFT",
2936
+ }]);
2937
+ });
2938
+
2553
2939
  test("uses backspace as back only when a pane-settings route query is empty", async () => {
2554
2940
  testSetup = await testRender(<CommandBarHarness
2555
2941
  query="PS"
@@ -2559,41 +2945,12 @@ describe("CommandBar", () => {
2559
2945
  })}
2560
2946
  hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
2561
2947
  configurePluginRegistry={(pluginRegistry) => {
2562
- pluginRegistry.resolvePaneSettings = () => ({
2563
- paneId: "quote-monitor:main",
2564
- pane: {
2565
- instanceId: "quote-monitor:main",
2566
- paneId: "quote-monitor",
2567
- title: "Quote Monitor",
2568
- settings: {},
2569
- },
2570
- paneDef: pluginRegistry.panes.get("quote-monitor")!,
2571
- settingsDef: {
2572
- title: "Quote Monitor Settings",
2573
- fields: [{
2574
- key: "symbol",
2575
- label: "Symbol",
2576
- type: "text",
2577
- description: "Ticker symbol to track",
2578
- }],
2579
- },
2580
- context: {
2581
- config: createDefaultConfig("/tmp/gloomberb-test"),
2582
- layout: cloneLayout(createDefaultConfig("/tmp/gloomberb-test").layout),
2583
- paneId: "quote-monitor:main",
2584
- paneType: "quote-monitor",
2585
- pane: {
2586
- instanceId: "quote-monitor:main",
2587
- paneId: "quote-monitor",
2588
- title: "Quote Monitor",
2589
- settings: {},
2590
- },
2591
- settings: {},
2592
- paneState: {},
2593
- activeTicker: "AAPL",
2594
- activeCollectionId: "main",
2595
- },
2596
- }) as any;
2948
+ pluginRegistry.resolvePaneSettings = () => makeQuoteMonitorPaneSettingsDescriptor(pluginRegistry, [{
2949
+ key: "symbol",
2950
+ label: "Symbol",
2951
+ type: "text",
2952
+ description: "Ticker symbol to track",
2953
+ }]);
2597
2954
  }}
2598
2955
  />, {
2599
2956
  width: 100,
@@ -2649,48 +3006,17 @@ describe("CommandBar", () => {
2649
3006
  })}
2650
3007
  hasPaneSettings={(paneId) => paneId === "quote-monitor:main"}
2651
3008
  configurePluginRegistry={(pluginRegistry) => {
2652
- pluginRegistry.resolvePaneSettings = () => ({
2653
- paneId: "quote-monitor:main",
2654
- pane: {
2655
- instanceId: "quote-monitor:main",
2656
- paneId: "quote-monitor",
2657
- title: "Quote Monitor",
2658
- settings: {},
2659
- },
2660
- paneDef: pluginRegistry.panes.get("quote-monitor")!,
2661
- settingsDef: {
2662
- title: "Quote Monitor Settings",
2663
- fields: [{
2664
- key: "columns",
2665
- label: "Columns",
2666
- type: "ordered-multi-select",
2667
- description: "Visible columns",
2668
- options: [
2669
- { value: "volume", label: "AAA", description: "Volume column" },
2670
- { value: "spread", label: "BBB", description: "Spread column" },
2671
- { value: "beta", label: "CCC", description: "Beta column" },
2672
- ],
2673
- }],
2674
- },
2675
- context: {
2676
- config: createDefaultConfig("/tmp/gloomberb-test"),
2677
- layout: cloneLayout(createDefaultConfig("/tmp/gloomberb-test").layout),
2678
- paneId: "quote-monitor:main",
2679
- paneType: "quote-monitor",
2680
- pane: {
2681
- instanceId: "quote-monitor:main",
2682
- paneId: "quote-monitor",
2683
- title: "Quote Monitor",
2684
- settings: {},
2685
- },
2686
- settings: {
2687
- columns: ["volume", "spread"],
2688
- },
2689
- paneState: {},
2690
- activeTicker: "AAPL",
2691
- activeCollectionId: "main",
2692
- },
2693
- }) as any;
3009
+ pluginRegistry.resolvePaneSettings = () => makeQuoteMonitorPaneSettingsDescriptor(pluginRegistry, [{
3010
+ key: "columns",
3011
+ label: "Columns",
3012
+ type: "ordered-multi-select",
3013
+ description: "Visible columns",
3014
+ options: [
3015
+ { value: "volume", label: "AAA", description: "Volume column" },
3016
+ { value: "spread", label: "BBB", description: "Spread column" },
3017
+ { value: "beta", label: "CCC", description: "Beta column" },
3018
+ ],
3019
+ }], { columns: ["volume", "spread"] });
2694
3020
  }}
2695
3021
  />, {
2696
3022
  width: 100,