gloomberb 0.3.3 → 0.4.0

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 (143) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -18,11 +18,12 @@ import {
18
18
  import { getFocusedCollectionId, useAppState, useFocusedTicker } from "../../state/app-context";
19
19
  import { fuzzyFilter } from "../../utils/fuzzy-search";
20
20
  import { commands, getThemeOptions, matchPrefix, type Command } from "./command-registry";
21
- import { applyTheme, getCurrentThemeId } from "../../theme/colors";
21
+ import { applyTheme } from "../../theme/colors";
22
22
  import { exportConfig, importConfig, resetAllData, saveConfig } from "../../data/config-store";
23
23
  import type { DataProvider } from "../../types/data-provider";
24
24
  import type { TickerRepository } from "../../data/ticker-repository";
25
25
  import type { PluginRegistry } from "../../plugins/registry";
26
+ import type { TickerRecord } from "../../types/ticker";
26
27
  import type {
27
28
  CommandDef,
28
29
  PaneSettingField,
@@ -68,6 +69,7 @@ import {
68
69
  type TickerSearchCandidate,
69
70
  } from "../../utils/ticker-search";
70
71
  import { debugLog } from "../../utils/debug-log";
72
+ import { isPlainBackspace } from "../../utils/back-navigation";
71
73
  import type {
72
74
  CommandBarFieldOption,
73
75
  CommandBarFieldValue,
@@ -78,11 +80,13 @@ import type {
78
80
  CommandBarWorkflowRoute,
79
81
  } from "./workflow-types";
80
82
  import { parseRootShortcutIntent } from "./root-shortcuts";
83
+ import { getPaneTemplateDisplayLabel } from "./pane-template-display";
81
84
  import {
82
85
  applyCollectionMembershipChange,
83
86
  getCollectionTargetOptions,
84
87
  resolvePreferredCollectionTarget,
85
88
  resolveTickerInput,
89
+ resolveTickerInputOrThrow,
86
90
  resolveTickerListInput,
87
91
  } from "./workflow-ops";
88
92
  import {
@@ -112,6 +116,19 @@ import {
112
116
  summarizeWorkflowFieldValue,
113
117
  toggleSelectedValue,
114
118
  } from "./helpers";
119
+ import {
120
+ buildAddToPortfolioWorkflow,
121
+ buildSetPortfolioPositionWorkflow,
122
+ } from "../../plugins/builtin/portfolio-list/command-bar";
123
+ import {
124
+ addTickerToPortfolio,
125
+ createManualPortfolio as createManualPortfolioConfig,
126
+ deleteManualPortfolio,
127
+ isManualPortfolio,
128
+ removeTickerFromPortfolio,
129
+ resolveManualPositionCurrency,
130
+ setManualPortfolioPosition,
131
+ } from "../../plugins/builtin/portfolio-list/mutations";
115
132
 
116
133
  interface CommandBarProps {
117
134
  dataProvider: DataProvider;
@@ -433,7 +450,7 @@ export function CommandBar({
433
450
  query: initialQuery,
434
451
  selectedIdx: 0,
435
452
  hoveredIdx: null,
436
- themeBaseId: screen === "themes" ? getCurrentThemeId() : undefined,
453
+ themeBaseId: screen === "themes" ? stateRef.current.config.theme : undefined,
437
454
  payload,
438
455
  });
439
456
  }, [pushRoute]);
@@ -590,6 +607,39 @@ export function CommandBar({
590
607
  return next;
591
608
  }, []);
592
609
 
610
+ const openAddToPortfolioWorkflow = useCallback((
611
+ ticker: TickerRecord,
612
+ preferredPortfolioId?: string | null,
613
+ ) => {
614
+ const defaultAvgCost = stateRef.current.financials.get(ticker.metadata.ticker)?.quote?.price ?? null;
615
+ const workflow = buildAddToPortfolioWorkflow(stateRef.current.config, {
616
+ preferredPortfolioId,
617
+ ticker,
618
+ defaultAvgCost,
619
+ });
620
+ if (!workflow) {
621
+ pluginRegistry.showToastFn("Create a manual portfolio first.", { type: "info" });
622
+ return;
623
+ }
624
+
625
+ openWorkflowRoute({
626
+ kind: "workflow",
627
+ workflowId: "builtin:add-portfolio",
628
+ title: `Add ${ticker.metadata.ticker} to Portfolio`,
629
+ subtitle: "Choose a portfolio and optionally record the manual position now.",
630
+ fields: workflow.fields,
631
+ values: workflow.values,
632
+ activeFieldId: getFirstVisibleFieldId(workflow.fields, workflow.values),
633
+ submitLabel: "Add to Portfolio",
634
+ cancelLabel: "Back",
635
+ pendingLabel: workflow.pendingLabel,
636
+ pending: false,
637
+ error: null,
638
+ successBehavior: "close",
639
+ payload: { kind: "builtin", actionId: "add-portfolio" },
640
+ });
641
+ }, [openWorkflowRoute, pluginRegistry]);
642
+
593
643
  const connectBrokerProfile = useCallback(async (
594
644
  brokerId: string,
595
645
  values: WorkflowStringValues,
@@ -625,25 +675,15 @@ export function CommandBar({
625
675
 
626
676
  const createManualPortfolio = useCallback(async (name: string) => {
627
677
  const currentState = stateRef.current;
628
- const trimmedName = name.trim();
629
- if (!trimmedName) {
630
- throw new Error("Portfolio name is required.");
631
- }
632
-
633
- const id = slugifyName(trimmedName, "portfolio");
634
- const newPortfolio = {
635
- id,
636
- name: trimmedName,
637
- currency: currentState.config.baseCurrency || "USD",
638
- };
639
- const nextConfig = {
640
- ...currentState.config,
641
- portfolios: [...currentState.config.portfolios, newPortfolio],
642
- };
678
+ const { config: nextConfig, portfolio } = createManualPortfolioConfig(
679
+ currentState.config,
680
+ name,
681
+ currentState.config.baseCurrency,
682
+ );
643
683
  dispatch({ type: "SET_CONFIG", config: nextConfig });
644
- setActiveCollection(id);
684
+ setActiveCollection(portfolio.id);
645
685
  await saveConfig(nextConfig);
646
- pluginRegistry.showToastFn(`Created portfolio "${trimmedName}".`, { type: "success" });
686
+ pluginRegistry.showToastFn(`Created portfolio "${portfolio.name}".`, { type: "success" });
647
687
  }, [dispatch, pluginRegistry, setActiveCollection]);
648
688
 
649
689
  const createWatchlist = useCallback(async (name: string) => {
@@ -691,11 +731,21 @@ export function CommandBar({
691
731
  if (!portfolio) {
692
732
  throw new Error("Portfolio not found.");
693
733
  }
734
+ if (!isManualPortfolio(portfolio)) {
735
+ throw new Error("Broker-managed portfolios cannot be deleted here.");
736
+ }
694
737
 
695
- const nextConfig = {
696
- ...currentState.config,
697
- portfolios: currentState.config.portfolios.filter((entry) => entry.id !== portfolioId),
698
- };
738
+ const result = deleteManualPortfolio(
739
+ currentState.config,
740
+ [...currentState.tickers.values()],
741
+ portfolioId,
742
+ );
743
+ for (const ticker of result.tickers) {
744
+ await tickerRepository.saveTicker(ticker);
745
+ dispatch({ type: "UPDATE_TICKER", ticker });
746
+ }
747
+
748
+ const nextConfig = result.config;
699
749
  dispatch({ type: "SET_CONFIG", config: nextConfig });
700
750
  if (activeCollectionId === portfolioId) {
701
751
  const fallback = nextConfig.portfolios[0]?.id || nextConfig.watchlists[0]?.id || "";
@@ -703,7 +753,87 @@ export function CommandBar({
703
753
  }
704
754
  await saveConfig(nextConfig);
705
755
  pluginRegistry.showToastFn(`Deleted "${portfolio.name}".`, { type: "success" });
706
- }, [activeCollectionId, dispatch, pluginRegistry, setActiveCollection]);
756
+ }, [activeCollectionId, dispatch, pluginRegistry, setActiveCollection, tickerRepository]);
757
+
758
+ const setPortfolioPositionFromWorkflow = useCallback(async (values: Record<string, CommandBarFieldValue>) => {
759
+ const currentState = stateRef.current;
760
+ const portfolioId = coerceFieldString(values.portfolioId).trim();
761
+ const portfolio = currentState.config.portfolios.find((entry) => entry.id === portfolioId);
762
+ if (!portfolio || !isManualPortfolio(portfolio)) {
763
+ throw new Error("Choose a manual portfolio.");
764
+ }
765
+
766
+ const shares = Number(coerceFieldString(values.shares));
767
+ if (!Number.isFinite(shares) || shares <= 0) {
768
+ throw new Error("Shares must be greater than 0.");
769
+ }
770
+
771
+ const avgCost = Number(coerceFieldString(values.avgCost));
772
+ if (!Number.isFinite(avgCost)) {
773
+ throw new Error("Avg Cost must be a valid number.");
774
+ }
775
+
776
+ const resolvedTicker = await resolveTickerInputOrThrow(
777
+ coerceFieldString(values.ticker),
778
+ activeTickerSymbol,
779
+ activeCollectionId,
780
+ {
781
+ dataProvider,
782
+ tickerRepository,
783
+ pluginRegistry,
784
+ dispatch,
785
+ getState: () => stateRef.current,
786
+ },
787
+ );
788
+
789
+ const currency = resolveManualPositionCurrency(
790
+ coerceFieldString(values.currency),
791
+ resolvedTicker.ticker,
792
+ portfolio,
793
+ currentState.config.baseCurrency,
794
+ );
795
+
796
+ const result = setManualPortfolioPosition(resolvedTicker.ticker, portfolio.id, {
797
+ shares,
798
+ avgCost,
799
+ currency,
800
+ });
801
+ await tickerRepository.saveTicker(result.ticker);
802
+ dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
803
+ pluginRegistry.showToastFn(`Set position for ${result.ticker.metadata.ticker} in "${portfolio.name}".`, { type: "success" });
804
+ }, [activeCollectionId, activeTickerSymbol, dataProvider, dispatch, pluginRegistry, tickerRepository]);
805
+
806
+ const addTickerMembershipFromWorkflow = useCallback(async (values: Record<string, CommandBarFieldValue>) => {
807
+ const currentState = stateRef.current;
808
+ const portfolioId = coerceFieldString(values.portfolioId).trim();
809
+ const portfolio = currentState.config.portfolios.find((entry) => entry.id === portfolioId);
810
+ if (!portfolio || !isManualPortfolio(portfolio)) {
811
+ throw new Error("Choose a manual portfolio.");
812
+ }
813
+
814
+ const resolvedTicker = await resolveTickerInputOrThrow(
815
+ coerceFieldString(values.ticker),
816
+ activeTickerSymbol,
817
+ activeCollectionId,
818
+ {
819
+ dataProvider,
820
+ tickerRepository,
821
+ pluginRegistry,
822
+ dispatch,
823
+ getState: () => stateRef.current,
824
+ },
825
+ );
826
+
827
+ const result = addTickerToPortfolio(resolvedTicker.ticker, portfolio.id);
828
+ if (result.changed) {
829
+ await tickerRepository.saveTicker(result.ticker);
830
+ dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
831
+ pluginRegistry.showToastFn(`Added ${result.ticker.metadata.ticker} to "${portfolio.name}".`, { type: "success" });
832
+ return;
833
+ }
834
+
835
+ pluginRegistry.showToastFn(`${result.ticker.metadata.ticker} is already in "${portfolio.name}".`, { type: "info" });
836
+ }, [activeCollectionId, activeTickerSymbol, dataProvider, dispatch, pluginRegistry, tickerRepository]);
707
837
 
708
838
  const disconnectBrokerInstance = useCallback(async (instanceId: string) => {
709
839
  const instance = stateRef.current.config.brokerInstances.find((entry) => entry.id === instanceId);
@@ -783,6 +913,33 @@ export function CommandBar({
783
913
  openWorkflowRoute(workflow);
784
914
  return;
785
915
  }
916
+ case "set-portfolio-position": {
917
+ const workflow = buildSetPortfolioPositionWorkflow(state.config, {
918
+ activeCollectionId,
919
+ activeTicker: activeTickerData,
920
+ });
921
+ if (!workflow) {
922
+ pluginRegistry.showToastFn("Create a manual portfolio first.", { type: "info" });
923
+ return;
924
+ }
925
+ openWorkflowRoute({
926
+ kind: "workflow",
927
+ workflowId: "builtin:set-portfolio-position",
928
+ title: "Set Portfolio Position",
929
+ subtitle: "Create or update a manual position without leaving the command bar.",
930
+ fields: workflow.fields,
931
+ values: workflow.values,
932
+ activeFieldId: getFirstVisibleFieldId(workflow.fields, workflow.values),
933
+ submitLabel: "Save Position",
934
+ cancelLabel: "Back",
935
+ pendingLabel: workflow.pendingLabel,
936
+ pending: false,
937
+ error: null,
938
+ successBehavior: "close",
939
+ payload: { kind: "builtin", actionId },
940
+ });
941
+ return;
942
+ }
786
943
  case "add-broker-account": {
787
944
  const workflow = buildBrokerWorkflow(
788
945
  "brokerType",
@@ -801,7 +958,7 @@ export function CommandBar({
801
958
  default:
802
959
  return;
803
960
  }
804
- }, [buildBrokerWorkflow, openWorkflowRoute, pluginRegistry, state.config.activeLayoutIndex, state.config.layouts]);
961
+ }, [activeCollectionId, activeTickerData, buildBrokerWorkflow, openWorkflowRoute, pluginRegistry, state.config]);
805
962
 
806
963
  const openPickerRoute = useCallback((
807
964
  route: CommandBarRoute,
@@ -912,6 +1069,55 @@ export function CommandBar({
912
1069
  return;
913
1070
  }
914
1071
 
1072
+ if (kind === "portfolio" && action === "add") {
1073
+ const manualPortfolios = stateForCommand.config.portfolios.filter(isManualPortfolio);
1074
+ if (manualPortfolios.length === 0) {
1075
+ pluginRegistry.showToastFn("Create a manual portfolio first.", { type: "info" });
1076
+ return;
1077
+ }
1078
+
1079
+ const preferredTargetId = explicitTargetId
1080
+ ?? (activeCollectionId && manualPortfolios.some((portfolio) => portfolio.id === activeCollectionId)
1081
+ ? activeCollectionId
1082
+ : manualPortfolios.length === 1
1083
+ ? manualPortfolios[0]!.id
1084
+ : null);
1085
+
1086
+ if (!preferredTargetId) {
1087
+ const options = manualPortfolios.map((portfolio) => {
1088
+ const isMember = resolvedTicker.ticker.metadata.portfolios.includes(portfolio.id);
1089
+ const description = isMember
1090
+ ? `Update position in "${portfolio.name}"`
1091
+ : `Add to "${portfolio.name}"`;
1092
+ return {
1093
+ id: portfolio.id,
1094
+ label: portfolio.name,
1095
+ detail: description,
1096
+ description,
1097
+ };
1098
+ });
1099
+ openPickerRoute({
1100
+ kind: "picker",
1101
+ pickerId: "collection-target",
1102
+ title: `Add ${resolvedTicker.symbol} to Portfolio`,
1103
+ query: "",
1104
+ selectedIdx: 0,
1105
+ hoveredIdx: null,
1106
+ options,
1107
+ payload: {
1108
+ commandId,
1109
+ kind,
1110
+ action,
1111
+ symbol: resolvedTicker.symbol,
1112
+ },
1113
+ });
1114
+ return;
1115
+ }
1116
+
1117
+ openAddToPortfolioWorkflow(resolvedTicker.ticker, preferredTargetId);
1118
+ return;
1119
+ }
1120
+
915
1121
  const targetId = explicitTargetId
916
1122
  ?? resolvePreferredCollectionTarget(
917
1123
  stateForCommand,
@@ -956,13 +1162,32 @@ export function CommandBar({
956
1162
  return;
957
1163
  }
958
1164
 
959
- const { changed } = await applyCollectionMembershipChange(
960
- resolvedTicker.ticker,
961
- kind,
962
- action,
963
- targetId,
964
- deps,
965
- );
1165
+ let changed = false;
1166
+ if (kind === "watchlist") {
1167
+ ({ changed } = await applyCollectionMembershipChange(
1168
+ resolvedTicker.ticker,
1169
+ kind,
1170
+ action,
1171
+ targetId,
1172
+ deps,
1173
+ ));
1174
+ } else {
1175
+ const portfolio = stateForCommand.config.portfolios.find((entry) => entry.id === targetId);
1176
+ if (!portfolio || !isManualPortfolio(portfolio)) {
1177
+ pluginRegistry.showToastFn("Choose a manual portfolio.", { type: "error" });
1178
+ return;
1179
+ }
1180
+
1181
+ const result = action === "add"
1182
+ ? addTickerToPortfolio(resolvedTicker.ticker, targetId)
1183
+ : removeTickerFromPortfolio(resolvedTicker.ticker, targetId);
1184
+
1185
+ changed = result.changed;
1186
+ if (result.changed) {
1187
+ await deps.tickerRepository.saveTicker(result.ticker);
1188
+ deps.dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
1189
+ }
1190
+ }
966
1191
  const targetName = (kind === "watchlist"
967
1192
  ? stateForCommand.config.watchlists.find((entry) => entry.id === targetId)?.name
968
1193
  : stateForCommand.config.portfolios.find((entry) => entry.id === targetId)?.name) || targetId;
@@ -985,6 +1210,7 @@ export function CommandBar({
985
1210
  activeTickerSymbol,
986
1211
  buildSharedWorkflowDeps,
987
1212
  closeAll,
1213
+ openAddToPortfolioWorkflow,
988
1214
  openModeRoute,
989
1215
  openPickerRoute,
990
1216
  pluginRegistry,
@@ -1234,6 +1460,18 @@ export function CommandBar({
1234
1460
  await connectBrokerProfile(brokerId, values);
1235
1461
  break;
1236
1462
  }
1463
+ case "add-portfolio": {
1464
+ const shares = coerceFieldString(route.values.shares).trim();
1465
+ if (!shares) {
1466
+ await addTickerMembershipFromWorkflow(route.values);
1467
+ } else {
1468
+ await setPortfolioPositionFromWorkflow(route.values);
1469
+ }
1470
+ break;
1471
+ }
1472
+ case "set-portfolio-position":
1473
+ await setPortfolioPositionFromWorkflow(route.values);
1474
+ break;
1237
1475
  default:
1238
1476
  break;
1239
1477
  }
@@ -1329,6 +1567,8 @@ export function CommandBar({
1329
1567
  extractBrokerWorkflowValues,
1330
1568
  getWorkflowFieldStringValue,
1331
1569
  pluginRegistry,
1570
+ addTickerMembershipFromWorkflow,
1571
+ setPortfolioPositionFromWorkflow,
1332
1572
  state.config.activeLayoutIndex,
1333
1573
  syncActiveWorkflowTextarea,
1334
1574
  updateTopRoute,
@@ -1375,6 +1615,7 @@ export function CommandBar({
1375
1615
  }, []);
1376
1616
 
1377
1617
  const openPaneTemplateWorkflow = useCallback((template: PaneTemplateDef, options?: { arg?: string }) => {
1618
+ const displayLabel = getPaneTemplateDisplayLabel(template);
1378
1619
  const normalized = template.wizard && template.wizard.length > 0
1379
1620
  ? normalizeWizardFields(template.wizard)
1380
1621
  : { fields: [] as CommandBarWorkflowField[], description: [] as string[], initialValues: {} as Record<string, CommandBarFieldValue> };
@@ -1395,7 +1636,7 @@ export function CommandBar({
1395
1636
  openWorkflowRoute({
1396
1637
  kind: "workflow",
1397
1638
  workflowId: `pane-template:${template.id}`,
1398
- title: template.label,
1639
+ title: displayLabel,
1399
1640
  subtitle: template.description,
1400
1641
  description: normalized.description,
1401
1642
  fields,
@@ -1403,7 +1644,7 @@ export function CommandBar({
1403
1644
  activeFieldId: getFirstVisibleFieldId(fields, values),
1404
1645
  submitLabel: "Create Pane",
1405
1646
  cancelLabel: "Back",
1406
- pendingLabel: normalized.pendingLabel ?? `Creating ${template.label.toLowerCase()}…`,
1647
+ pendingLabel: normalized.pendingLabel ?? `Creating ${displayLabel.toLowerCase()}…`,
1407
1648
  successLabel: normalized.successLabel,
1408
1649
  pending: false,
1409
1650
  error: null,
@@ -1426,8 +1667,9 @@ export function CommandBar({
1426
1667
  await pluginRegistry.createPaneFromTemplateAsyncFn(template.id, createOptions);
1427
1668
  closeAll({ revertThemePreview: false });
1428
1669
  } catch (error) {
1670
+ const displayLabel = getPaneTemplateDisplayLabel(template);
1429
1671
  pluginRegistry.showToastFn(
1430
- error instanceof Error ? error.message : `Could not create ${template.label.toLowerCase()}.`,
1672
+ error instanceof Error ? error.message : `Could not create ${displayLabel.toLowerCase()}.`,
1431
1673
  { type: "error" },
1432
1674
  );
1433
1675
  }
@@ -1701,13 +1943,12 @@ export function CommandBar({
1701
1943
  ): ResultItem => {
1702
1944
  const pluginId = pluginRegistry.getPaneTemplatePluginId(template.id);
1703
1945
  const pluginName = pluginId ? pluginRegistry.allPlugins.get(pluginId)?.name : null;
1704
- const paneDef = pluginRegistry.panes.get(template.paneId);
1946
+ const displayLabel = getPaneTemplateDisplayLabel(template);
1705
1947
  const shortcutLabel = template.shortcut
1706
1948
  ? [template.shortcut.prefix, template.shortcut.argPlaceholder && `<${template.shortcut.argPlaceholder}>`]
1707
1949
  .filter(Boolean)
1708
1950
  .join(" ")
1709
1951
  : null;
1710
- const placementLabel = paneDef?.defaultMode === "floating" ? "float" : "dock";
1711
1952
  const arg = options?.createOptions?.arg;
1712
1953
 
1713
1954
  const action = () => {
@@ -1724,12 +1965,12 @@ export function CommandBar({
1724
1965
 
1725
1966
  return {
1726
1967
  id: `pane-template:${template.id}:${arg || ""}`,
1727
- label: template.label,
1968
+ label: displayLabel,
1728
1969
  detail: shortcutLabel ? `${template.description} · ${shortcutLabel}` : template.description,
1729
1970
  category: options?.category ?? (pluginName ? `${pluginName} Panes` : "New Panes"),
1730
1971
  kind: "action",
1731
- right: options?.showShortcut ? (template.shortcut?.prefix || placementLabel) : placementLabel,
1732
- searchText: `${template.paneId} ${template.keywords?.join(" ") || ""} ${shortcutLabel || ""} ${pluginName || ""}`,
1972
+ right: options?.showShortcut ? template.shortcut?.prefix : undefined,
1973
+ searchText: `${displayLabel} ${template.label} ${template.paneId} ${template.keywords?.join(" ") || ""} ${shortcutLabel || ""} ${pluginName || ""}`,
1733
1974
  action,
1734
1975
  };
1735
1976
  }, [openPaneTemplateDirect, openPaneTemplateWorkflow, pluginRegistry, runPaneTemplateShortcut, shouldOpenTemplateConfig]);
@@ -1863,11 +2104,17 @@ export function CommandBar({
1863
2104
  case "add-broker-account":
1864
2105
  case "new-portfolio":
1865
2106
  case "new-watchlist":
2107
+ case "set-portfolio-position":
1866
2108
  case "disconnect-broker-account":
1867
2109
  case "delete-watchlist":
1868
2110
  case "delete-portfolio":
1869
2111
  case "reset-all-data":
1870
- if (command.id === "add-broker-account" || command.id === "new-portfolio" || command.id === "new-watchlist") {
2112
+ if (
2113
+ command.id === "add-broker-account"
2114
+ || command.id === "new-portfolio"
2115
+ || command.id === "new-watchlist"
2116
+ || command.id === "set-portfolio-position"
2117
+ ) {
1871
2118
  openBuiltInWorkflow(command.id);
1872
2119
  return;
1873
2120
  }
@@ -1914,7 +2161,7 @@ export function CommandBar({
1914
2161
  return;
1915
2162
  }
1916
2163
  if (command.id === "delete-portfolio") {
1917
- const deletable = state.config.portfolios.filter((portfolio) => !portfolio.brokerId && !portfolio.brokerInstanceId);
2164
+ const deletable = state.config.portfolios.filter(isManualPortfolio);
1918
2165
  const options = deletable.map((portfolio) => ({
1919
2166
  id: portfolio.id,
1920
2167
  label: portfolio.name,
@@ -2160,6 +2407,7 @@ export function CommandBar({
2160
2407
 
2161
2408
  const isWatchlistTab = state.config.watchlists.some((entry) => entry.id === activeCollectionId);
2162
2409
  const isPortfolioTab = state.config.portfolios.some((entry) => entry.id === activeCollectionId);
2410
+ const manualPortfolios = state.config.portfolios.filter(isManualPortfolio);
2163
2411
  const tickerData = activeTickerData;
2164
2412
  const focusedPaneHasSettings = !!state.focusedPaneId && pluginRegistry.hasPaneSettings(state.focusedPaneId);
2165
2413
 
@@ -2167,8 +2415,8 @@ export function CommandBar({
2167
2415
  ? activeCollectionId
2168
2416
  : state.config.watchlists[0]?.id ?? null;
2169
2417
  const targetPortfolioId = isPortfolioTab
2170
- ? activeCollectionId
2171
- : state.config.portfolios.find((entry) => !entry.brokerId)?.id ?? null;
2418
+ ? manualPortfolios.find((entry) => entry.id === activeCollectionId)?.id ?? null
2419
+ : manualPortfolios[0]?.id ?? null;
2172
2420
 
2173
2421
  function shouldShow(command: Command): boolean {
2174
2422
  switch (command.id) {
@@ -2177,16 +2425,18 @@ export function CommandBar({
2177
2425
  case "remove-watchlist":
2178
2426
  return !!tickerData && tickerData.metadata.watchlists.length > 0;
2179
2427
  case "add-portfolio":
2180
- return !!tickerData && !!targetPortfolioId && !tickerData.metadata.portfolios.includes(targetPortfolioId);
2428
+ return !!tickerData && manualPortfolios.length > 0;
2181
2429
  case "remove-portfolio":
2182
2430
  return !!tickerData && tickerData.metadata.portfolios.some((id) =>
2183
- state.config.portfolios.some((entry) => entry.id === id && !entry.brokerId));
2431
+ state.config.portfolios.some((entry) => entry.id === id && isManualPortfolio(entry)));
2432
+ case "set-portfolio-position":
2433
+ return manualPortfolios.length > 0;
2184
2434
  case "disconnect-broker-account":
2185
2435
  return state.config.brokerInstances.length > 0;
2186
2436
  case "delete-watchlist":
2187
2437
  return state.config.watchlists.length > 0;
2188
2438
  case "delete-portfolio":
2189
- return state.config.portfolios.some((entry) => !entry.brokerId && !entry.brokerInstanceId);
2439
+ return manualPortfolios.length > 0;
2190
2440
  case "pane-settings":
2191
2441
  return focusedPaneHasSettings;
2192
2442
  default:
@@ -2204,6 +2454,8 @@ export function CommandBar({
2204
2454
  return activeTickerSymbol ? `Add ${activeTickerSymbol} to Portfolio` : command.label;
2205
2455
  case "remove-portfolio":
2206
2456
  return activeTickerSymbol ? `Remove ${activeTickerSymbol} from Portfolio` : command.label;
2457
+ case "set-portfolio-position":
2458
+ return activeTickerSymbol ? `Set Position for ${activeTickerSymbol}` : command.label;
2207
2459
  default:
2208
2460
  return command.label;
2209
2461
  }
@@ -2227,10 +2479,14 @@ export function CommandBar({
2227
2479
  }
2228
2480
  case "remove-portfolio": {
2229
2481
  const names = tickerData?.metadata.portfolios
2230
- .map((id) => state.config.portfolios.find((entry) => entry.id === id && !entry.brokerId)?.name)
2482
+ .map((id) => state.config.portfolios.find((entry) => entry.id === id && isManualPortfolio(entry))?.name)
2231
2483
  .filter(Boolean);
2232
2484
  return names?.length ? `from "${names.join(", ")}"` : command.description;
2233
2485
  }
2486
+ case "set-portfolio-position": {
2487
+ const name = state.config.portfolios.find((entry) => entry.id === targetPortfolioId)?.name;
2488
+ return name ? `in "${name}"` : command.description;
2489
+ }
2234
2490
  case "check-for-updates":
2235
2491
  if (state.updateCheckInProgress) return "Checking GitHub releases now";
2236
2492
  if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
@@ -2241,6 +2497,15 @@ export function CommandBar({
2241
2497
  }
2242
2498
  }
2243
2499
 
2500
+ function smartSearchText(command: Command): string {
2501
+ switch (command.id) {
2502
+ case "set-portfolio-position":
2503
+ return "edit position update position modify position manual position portfolio position";
2504
+ default:
2505
+ return "";
2506
+ }
2507
+ }
2508
+
2244
2509
  function commandToItem(command: Command): ResultItem | null {
2245
2510
  if (!shouldShow(command)) return null;
2246
2511
  return {
@@ -2250,6 +2515,7 @@ export function CommandBar({
2250
2515
  category: command.category,
2251
2516
  kind: "command",
2252
2517
  right: command.prefix || undefined,
2518
+ searchText: smartSearchText(command),
2253
2519
  disabled: command.id === "check-for-updates" && (state.updateCheckInProgress || !!state.updateProgress),
2254
2520
  action: () => runDirectCommand(command, ""),
2255
2521
  };
@@ -2291,13 +2557,19 @@ export function CommandBar({
2291
2557
  const displayTicker = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2292
2558
  const displayName = kind === "watchlist" ? "Watchlist" : "Portfolio";
2293
2559
  const localTicker = displayTicker ? state.tickers.get(displayTicker) ?? null : null;
2294
- const preferredTargetId = resolvePreferredCollectionTarget(
2295
- state,
2296
- kind,
2297
- activeCollectionId,
2298
- action,
2299
- localTicker,
2300
- );
2560
+ const preferredTargetId = commandId === "add-portfolio"
2561
+ ? (activeCollectionId && manualPortfolios.some((portfolio) => portfolio.id === activeCollectionId)
2562
+ ? activeCollectionId
2563
+ : manualPortfolios.length === 1
2564
+ ? manualPortfolios[0]!.id
2565
+ : null)
2566
+ : resolvePreferredCollectionTarget(
2567
+ state,
2568
+ kind,
2569
+ activeCollectionId,
2570
+ action,
2571
+ localTicker,
2572
+ );
2301
2573
  const preferredTargetName = preferredTargetId
2302
2574
  ? (kind === "watchlist"
2303
2575
  ? state.config.watchlists.find((entry) => entry.id === preferredTargetId)?.name
@@ -2872,22 +3144,22 @@ export function CommandBar({
2872
3144
  const symbol = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2873
3145
  if (symbol) {
2874
3146
  return rootShortcutIntent.kind === "inferred-complete"
2875
- ? `Shortcut: ${rootShortcutIntent.template.label} for ${symbol} · Tab to accept`
2876
- : `Shortcut: ${rootShortcutIntent.template.label} for ${symbol}`;
3147
+ ? `Shortcut: ${rootShortcutIntent.label} for ${symbol} · Tab to accept`
3148
+ : `Shortcut: ${rootShortcutIntent.label} for ${symbol}`;
2877
3149
  }
2878
- return `Shortcut: ${rootShortcutIntent.template.label} · Enter to choose ticker`;
3150
+ return `Shortcut: ${rootShortcutIntent.label} · Enter to choose ticker`;
2879
3151
  }
2880
3152
  if (argKind === "ticker-list") {
2881
3153
  if (rootShortcutIntent.argText) {
2882
- return `Shortcut: ${rootShortcutIntent.template.label} · ${rootShortcutIntent.argText}`;
3154
+ return `Shortcut: ${rootShortcutIntent.label} · ${rootShortcutIntent.argText}`;
2883
3155
  }
2884
3156
  const inferred = normalizeTickerInput(activeTickerSymbol, undefined);
2885
3157
  if (inferred) {
2886
- return `Shortcut: ${rootShortcutIntent.template.label} · inferred ${inferred} · Tab to accept`;
3158
+ return `Shortcut: ${rootShortcutIntent.label} · inferred ${inferred} · Tab to accept`;
2887
3159
  }
2888
- return `Shortcut: ${rootShortcutIntent.template.label} · Enter tickers to compare`;
3160
+ return `Shortcut: ${rootShortcutIntent.label} · Enter tickers to compare`;
2889
3161
  }
2890
- return `Shortcut: ${rootShortcutIntent.template.label}`;
3162
+ return `Shortcut: ${rootShortcutIntent.label}`;
2891
3163
  }
2892
3164
 
2893
3165
  if (rootShortcutIntent.command.id === "search-ticker") {
@@ -3617,6 +3889,7 @@ export function CommandBar({
3617
3889
  visibleListStateRef.current = routeListState;
3618
3890
 
3619
3891
  useEffect(() => {
3892
+ if (!state.commandBarOpen) return;
3620
3893
  if (!routeListState) return;
3621
3894
  const selected = currentRoute?.kind === "mode" && currentRoute.screen === "themes"
3622
3895
  ? routeListState.results[currentRoute.selectedIdx]
@@ -3628,7 +3901,7 @@ export function CommandBar({
3628
3901
  applyTheme(selected.themeId);
3629
3902
  dispatch({ type: "SET_THEME", theme: selected.themeId });
3630
3903
  }
3631
- }, [currentRoute, dispatch, rootModeInfo.kind, rootSelectedIdx, routeListState, state.config.theme]);
3904
+ }, [currentRoute, dispatch, rootModeInfo.kind, rootSelectedIdx, routeListState, state.commandBarOpen, state.config.theme]);
3632
3905
 
3633
3906
  useEffect(() => {
3634
3907
  if (currentRoute?.kind !== "workflow") return;
@@ -4080,6 +4353,7 @@ export function CommandBar({
4080
4353
  name: string;
4081
4354
  sequence?: string;
4082
4355
  ctrl?: boolean;
4356
+ option?: boolean;
4083
4357
  meta?: boolean;
4084
4358
  shift?: boolean;
4085
4359
  stopPropagation: () => void;
@@ -4089,6 +4363,7 @@ export function CommandBar({
4089
4363
  name: string;
4090
4364
  sequence?: string;
4091
4365
  ctrl?: boolean;
4366
+ option?: boolean;
4092
4367
  meta?: boolean;
4093
4368
  shift?: boolean;
4094
4369
  stopPropagation: () => void;
@@ -4099,6 +4374,7 @@ export function CommandBar({
4099
4374
  name: string;
4100
4375
  sequence?: string;
4101
4376
  ctrl?: boolean;
4377
+ option?: boolean;
4102
4378
  meta?: boolean;
4103
4379
  shift?: boolean;
4104
4380
  stopPropagation: () => void;
@@ -4112,6 +4388,12 @@ export function CommandBar({
4112
4388
  }
4113
4389
 
4114
4390
  if (currentRoute?.kind === "confirm") {
4391
+ if (isPlainBackspace(event)) {
4392
+ event.stopPropagation();
4393
+ event.preventDefault();
4394
+ popRoute();
4395
+ return;
4396
+ }
4115
4397
  if (event.name === "return" || event.name === "enter" || event.name === "y") {
4116
4398
  event.stopPropagation();
4117
4399
  event.preventDefault();
@@ -4126,6 +4408,20 @@ export function CommandBar({
4126
4408
  return;
4127
4409
  }
4128
4410
 
4411
+ if (
4412
+ currentRoute
4413
+ && (currentRoute.kind === "mode"
4414
+ || currentRoute.kind === "picker"
4415
+ || currentRoute.kind === "pane-settings")
4416
+ && isPlainBackspace(event)
4417
+ && currentRoute.query.length === 0
4418
+ ) {
4419
+ event.stopPropagation();
4420
+ event.preventDefault();
4421
+ popRoute();
4422
+ return;
4423
+ }
4424
+
4129
4425
  if (currentRoute?.kind === "workflow") {
4130
4426
  const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
4131
4427
  const activeField = visibleFields.find((field) => field.id === currentRoute.activeFieldId) ?? visibleFields[0];