gloomberb 0.3.7 → 0.4.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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  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-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  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,11 +2479,19 @@ 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":
2491
+ if (state.updateProgress?.phase === "downloading") return `Downloading v${state.updateAvailable?.version}: ${state.updateProgress.percent ?? 0}%`;
2492
+ if (state.updateProgress?.phase === "replacing") return "Installing update";
2493
+ if (state.updateProgress?.phase === "done") return "Update installed - restart to apply";
2494
+ if (state.updateProgress?.phase === "error") return `Update failed: ${state.updateProgress.error}`;
2235
2495
  if (state.updateCheckInProgress) return "Checking GitHub releases now";
2236
2496
  if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
2237
2497
  if (state.updateNotice) return state.updateNotice;
@@ -2241,6 +2501,15 @@ export function CommandBar({
2241
2501
  }
2242
2502
  }
2243
2503
 
2504
+ function smartSearchText(command: Command): string {
2505
+ switch (command.id) {
2506
+ case "set-portfolio-position":
2507
+ return "edit position update position modify position manual position portfolio position";
2508
+ default:
2509
+ return "";
2510
+ }
2511
+ }
2512
+
2244
2513
  function commandToItem(command: Command): ResultItem | null {
2245
2514
  if (!shouldShow(command)) return null;
2246
2515
  return {
@@ -2250,6 +2519,7 @@ export function CommandBar({
2250
2519
  category: command.category,
2251
2520
  kind: "command",
2252
2521
  right: command.prefix || undefined,
2522
+ searchText: smartSearchText(command),
2253
2523
  disabled: command.id === "check-for-updates" && (state.updateCheckInProgress || !!state.updateProgress),
2254
2524
  action: () => runDirectCommand(command, ""),
2255
2525
  };
@@ -2291,13 +2561,19 @@ export function CommandBar({
2291
2561
  const displayTicker = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2292
2562
  const displayName = kind === "watchlist" ? "Watchlist" : "Portfolio";
2293
2563
  const localTicker = displayTicker ? state.tickers.get(displayTicker) ?? null : null;
2294
- const preferredTargetId = resolvePreferredCollectionTarget(
2295
- state,
2296
- kind,
2297
- activeCollectionId,
2298
- action,
2299
- localTicker,
2300
- );
2564
+ const preferredTargetId = commandId === "add-portfolio"
2565
+ ? (activeCollectionId && manualPortfolios.some((portfolio) => portfolio.id === activeCollectionId)
2566
+ ? activeCollectionId
2567
+ : manualPortfolios.length === 1
2568
+ ? manualPortfolios[0]!.id
2569
+ : null)
2570
+ : resolvePreferredCollectionTarget(
2571
+ state,
2572
+ kind,
2573
+ activeCollectionId,
2574
+ action,
2575
+ localTicker,
2576
+ );
2301
2577
  const preferredTargetName = preferredTargetId
2302
2578
  ? (kind === "watchlist"
2303
2579
  ? state.config.watchlists.find((entry) => entry.id === preferredTargetId)?.name
@@ -2872,22 +3148,22 @@ export function CommandBar({
2872
3148
  const symbol = normalizeTickerInput(activeTickerSymbol, rootShortcutIntent.argText);
2873
3149
  if (symbol) {
2874
3150
  return rootShortcutIntent.kind === "inferred-complete"
2875
- ? `Shortcut: ${rootShortcutIntent.template.label} for ${symbol} · Tab to accept`
2876
- : `Shortcut: ${rootShortcutIntent.template.label} for ${symbol}`;
3151
+ ? `Shortcut: ${rootShortcutIntent.label} for ${symbol} · Tab to accept`
3152
+ : `Shortcut: ${rootShortcutIntent.label} for ${symbol}`;
2877
3153
  }
2878
- return `Shortcut: ${rootShortcutIntent.template.label} · Enter to choose ticker`;
3154
+ return `Shortcut: ${rootShortcutIntent.label} · Enter to choose ticker`;
2879
3155
  }
2880
3156
  if (argKind === "ticker-list") {
2881
3157
  if (rootShortcutIntent.argText) {
2882
- return `Shortcut: ${rootShortcutIntent.template.label} · ${rootShortcutIntent.argText}`;
3158
+ return `Shortcut: ${rootShortcutIntent.label} · ${rootShortcutIntent.argText}`;
2883
3159
  }
2884
3160
  const inferred = normalizeTickerInput(activeTickerSymbol, undefined);
2885
3161
  if (inferred) {
2886
- return `Shortcut: ${rootShortcutIntent.template.label} · inferred ${inferred} · Tab to accept`;
3162
+ return `Shortcut: ${rootShortcutIntent.label} · inferred ${inferred} · Tab to accept`;
2887
3163
  }
2888
- return `Shortcut: ${rootShortcutIntent.template.label} · Enter tickers to compare`;
3164
+ return `Shortcut: ${rootShortcutIntent.label} · Enter tickers to compare`;
2889
3165
  }
2890
- return `Shortcut: ${rootShortcutIntent.template.label}`;
3166
+ return `Shortcut: ${rootShortcutIntent.label}`;
2891
3167
  }
2892
3168
 
2893
3169
  if (rootShortcutIntent.command.id === "search-ticker") {
@@ -3617,6 +3893,7 @@ export function CommandBar({
3617
3893
  visibleListStateRef.current = routeListState;
3618
3894
 
3619
3895
  useEffect(() => {
3896
+ if (!state.commandBarOpen) return;
3620
3897
  if (!routeListState) return;
3621
3898
  const selected = currentRoute?.kind === "mode" && currentRoute.screen === "themes"
3622
3899
  ? routeListState.results[currentRoute.selectedIdx]
@@ -3628,7 +3905,7 @@ export function CommandBar({
3628
3905
  applyTheme(selected.themeId);
3629
3906
  dispatch({ type: "SET_THEME", theme: selected.themeId });
3630
3907
  }
3631
- }, [currentRoute, dispatch, rootModeInfo.kind, rootSelectedIdx, routeListState, state.config.theme]);
3908
+ }, [currentRoute, dispatch, rootModeInfo.kind, rootSelectedIdx, routeListState, state.commandBarOpen, state.config.theme]);
3632
3909
 
3633
3910
  useEffect(() => {
3634
3911
  if (currentRoute?.kind !== "workflow") return;
@@ -4080,6 +4357,7 @@ export function CommandBar({
4080
4357
  name: string;
4081
4358
  sequence?: string;
4082
4359
  ctrl?: boolean;
4360
+ option?: boolean;
4083
4361
  meta?: boolean;
4084
4362
  shift?: boolean;
4085
4363
  stopPropagation: () => void;
@@ -4089,6 +4367,7 @@ export function CommandBar({
4089
4367
  name: string;
4090
4368
  sequence?: string;
4091
4369
  ctrl?: boolean;
4370
+ option?: boolean;
4092
4371
  meta?: boolean;
4093
4372
  shift?: boolean;
4094
4373
  stopPropagation: () => void;
@@ -4099,6 +4378,7 @@ export function CommandBar({
4099
4378
  name: string;
4100
4379
  sequence?: string;
4101
4380
  ctrl?: boolean;
4381
+ option?: boolean;
4102
4382
  meta?: boolean;
4103
4383
  shift?: boolean;
4104
4384
  stopPropagation: () => void;
@@ -4112,6 +4392,12 @@ export function CommandBar({
4112
4392
  }
4113
4393
 
4114
4394
  if (currentRoute?.kind === "confirm") {
4395
+ if (isPlainBackspace(event)) {
4396
+ event.stopPropagation();
4397
+ event.preventDefault();
4398
+ popRoute();
4399
+ return;
4400
+ }
4115
4401
  if (event.name === "return" || event.name === "enter" || event.name === "y") {
4116
4402
  event.stopPropagation();
4117
4403
  event.preventDefault();
@@ -4126,6 +4412,20 @@ export function CommandBar({
4126
4412
  return;
4127
4413
  }
4128
4414
 
4415
+ if (
4416
+ currentRoute
4417
+ && (currentRoute.kind === "mode"
4418
+ || currentRoute.kind === "picker"
4419
+ || currentRoute.kind === "pane-settings")
4420
+ && isPlainBackspace(event)
4421
+ && currentRoute.query.length === 0
4422
+ ) {
4423
+ event.stopPropagation();
4424
+ event.preventDefault();
4425
+ popRoute();
4426
+ return;
4427
+ }
4428
+
4129
4429
  if (currentRoute?.kind === "workflow") {
4130
4430
  const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
4131
4431
  const activeField = visibleFields.find((field) => field.id === currentRoute.activeFieldId) ?? visibleFields[0];