@omercnet/paseo-omp 0.3.0 → 0.4.0-next.114.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 (43) hide show
  1. package/README.md +19 -5
  2. package/client/omp-config-surface.tsx +243 -24
  3. package/client/omp-config-views.ts +24 -0
  4. package/client/omp-model-picker-state.ts +145 -0
  5. package/client/omp-model-picker.tsx +282 -0
  6. package/client/omp-routing-editor.tsx +307 -0
  7. package/client/support-diagnostics-state.ts +45 -0
  8. package/index.server.ts +27 -2
  9. package/package.json +2 -8
  10. package/paseo-plugin.json +1 -1
  11. package/server/omp-models.ts +59 -0
  12. package/server/omp-settings.ts +30 -20
  13. package/server/operational-failure-diagnostics.ts +76 -0
  14. package/server/package-version.ts +2 -0
  15. package/server/protocol-violation-diagnostics.ts +169 -0
  16. package/server/provider/catalog.ts +39 -10
  17. package/server/provider/connection.ts +60 -8
  18. package/server/provider/host-tools.ts +284 -34
  19. package/server/provider/mcp-transport.ts +2 -1
  20. package/server/provider/omp-rpc.ts +1011 -107
  21. package/server/provider/profile-providers.ts +7 -2
  22. package/server/provider/registration.ts +12 -2
  23. package/server/provider/security.ts +8 -10
  24. package/server/provider/session-descriptors.ts +45 -11
  25. package/server/provider/session.ts +200 -58
  26. package/server/provider/subsessions.ts +311 -73
  27. package/server/provider/timeline-projector.ts +34 -11
  28. package/server/support-diagnostics.ts +284 -0
  29. package/shared/omp-models.ts +49 -0
  30. package/shared/omp-settings.ts +227 -3
  31. package/shared/support-diagnostics.ts +32 -0
  32. package/CHANGELOG.md +0 -113
  33. package/SUPPORT.md +0 -44
  34. package/TESTING.md +0 -150
  35. package/docs/alpha-release-checklist.md +0 -68
  36. package/docs/configuration.md +0 -126
  37. package/docs/core-provider-issue-audit.md +0 -109
  38. package/docs/images/mcp-authorization-compact.png +0 -0
  39. package/docs/images/mcp-controls-wide.png +0 -0
  40. package/docs/images/plugin-manager.png +0 -0
  41. package/docs/images/workspace-settings.png +0 -0
  42. package/docs/installation.md +0 -89
  43. package/tsconfig.json +0 -16
package/README.md CHANGED
@@ -4,16 +4,30 @@ Community OMP integration for Paseo. The plugin registers the distinct `omp-plug
4
4
 
5
5
  > **Alpha preview:** persistence and protocol contracts are tested, but upgrades may still require re-importing sessions created by an earlier preview.
6
6
 
7
+ ## Screenshots
8
+
9
+ Both PNGs come from the plugin's isolated Paseo development daemon at 2× pixel density. Browser
10
+ DevTools rewrites local filesystem paths and verifies that no private organization names remain
11
+ before capture.
12
+
13
+ ### Native plugin manager
14
+
15
+ ![OMP native plugin manager](https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png)
16
+
17
+ ### Typed OMP configuration
18
+
19
+ ![OMP typed configuration editor](https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png)
20
+
7
21
  ## Quick start
8
22
 
9
- Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` is recommended for npm-managed installation and the complete client integration.
23
+ Requirements: Paseo `>=0.9.0-beta.1 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo 0.9 is required for the Help report's supported copy and external-link APIs, nested provider-subagent ancestry, spawning-tool links, npm-managed installation, and the complete client integration.
10
24
 
11
25
  ```bash
12
26
  paseo plugin install npm:@omercnet/paseo-omp@<version>
13
27
  paseo plugin ls paseo-omp
14
28
  ```
15
29
 
16
- Open the **OMP** sidebar to review the provider-profile contract, choose which composer pills appear, browse and edit native scalar settings, manage OMP-native plugins, verify runtime health, and inspect storage and process diagnostics. Then create an agent and select **OMP Plugin**. Its optional **MCP** composer control runs OMP-native management commands in the current session; setup questions and authorization stay in that chat timeline, where OAuth can open in a workspace-scoped Paseo Browser or on the current device. Composer pill preferences are shared by clients connected to the same host; normal use requires no other plugin-specific settings.
30
+ Open the **OMP** sidebar to review the provider-profile contract, choose which composer pills appear, browse and edit native scalar settings, manage OMP-native plugins, verify runtime health, and generate a bounded support report under **Help**. The same tabs are available in the workspace OMP panel, except for the global Composer preference tab. Then create an agent and select **OMP Plugin**. Its optional **MCP** composer control runs OMP-native management commands in the current session; setup questions and authorization stay in that chat timeline, where OAuth can open in a workspace-scoped Paseo Browser or on the current device. Composer pill preferences are shared by clients connected to the same host; normal use requires no other plugin-specific settings.
17
31
 
18
32
  - [Install, update, rollback, and local development](docs/installation.md)
19
33
  - [Configuration and every provider option](docs/configuration.md)
@@ -22,13 +36,13 @@ Open the **OMP** sidebar to review the provider-profile contract, choose which c
22
36
  - [Core-provider issue and parity audit](docs/core-provider-issue-audit.md)
23
37
  - [Alpha release checklist](docs/alpha-release-checklist.md)
24
38
 
25
- The plugin uses public Paseo 0.8 and 0.9 beta provider contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
39
+ The plugin uses public Paseo 0.9 beta provider and client contracts and registers distinct `omp-plugin` and `omp-plugin-<profile>` identities; it does not modify the bundled `omp` provider.
26
40
 
27
41
  ## Named OMP profiles
28
42
 
29
43
  At startup the plugin discovers directory names under `~/.omp/profiles/` (or `PI_CONFIG_DIR`) and registers **OMP · <profile>** for each valid lowercase name. Select that provider when creating a profile-backed agent. Discovery, launch, recovery and persisted session listing share its fixed profile and session root. Matching command wrappers, including Doppler, remain supported; conflicting profile or session-directory overrides fail before launch. Plain `env NAME=value omp` wrappers are supported, but environment-control flags such as `env -i` and `env -u` are rejected because they can discard the selected store. Profile-specific XDG roots cannot be overridden by per-launch environment values. Reload the plugin after adding a profile.
30
44
 
31
- Paseo 0.8 does not pass agent launch options to its model picker. Separate provider identities let the picker request the correct profile catalog before an agent exists. Existing `omp-plugin` and bundled `omp` agents retain their provider; they are not migrated automatically. Profile names use OMP's lowercase grammar, including dotted names such as `team.prod`.
45
+ Separate provider identities let the model picker request the correct profile catalog before an agent exists. Existing `omp-plugin` and bundled `omp` agents retain their provider; they are not migrated automatically. Profile names use OMP's lowercase grammar, including dotted names such as `team.prod`.
32
46
 
33
47
  The OMP sidebar and memory panel expose an explicit store selector. Settings, plugins, quota, history, memory and diagnostics use that selection, and profile-agent popovers derive it from the provider ID. OMP-compatible XDG data and state roots are honored when a migrated profile uses them. Default-provider views are labelled **Daemon default store**; changing `providerOptions.command` on the default provider does not make those views profile-aware. Workspace configuration remains project-scoped, with the selected profile supplying inherited settings. Hub records are daemon-wide. The RPCs also accept an explicit absolute `store.agentDir` for custom stores; it is mutually exclusive with `store.profile`.
34
48
 
@@ -99,7 +113,7 @@ Existing agents whose provider is `omp` remain owned by the bundled provider. Ne
99
113
 
100
114
  OMP `18.1.15` is the oldest version tested end to end. The direct provider's hard compatibility gate is `rpc-ui` protocol v2: metadata-free legacy ready frames and v1-only runtimes are rejected before a provider session opens because they cannot support the advertised persistence and conversation-rewind capabilities. Typed tool approvals remain capability-gated and fall back as described above. For terminal completion, a matching `agent_end.requestId` is authoritative and mismatches are ignored before state changes. Released OMP 18.2.x builds that omit that field use an ordered fallback requiring a fresh correlated native user entry followed by current-turn assistant activity, an idle non-compacting runtime, and no conflicting permission, tool, steer, or child-session work. Ambiguous events while OMP is active are ignored; unresolved ambiguity after confirmed idle fails only the Paseo turn. This trades a bounded residual same-agent stale-event risk for compatibility with released OMP instead of terminating and lazily restarting the runtime after every later prompt. Paseo clients, including mobile clients inside reconnect grace, do not own or terminate the daemon-managed OMP session.
101
115
 
102
- Paseo 0.9.0-beta.1 is the canary target and provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for provider operation and Git or directory installation; its clients do not provide the external URL opener and retain their older timeline presentation semantics.
116
+ Paseo 0.9.0-beta.1 is the verified canary target and minimum supported release. It preserves nested provider-subagent ancestry and spawning-tool links, and provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. The required full Docker canary passes there with OMP 18.1.15 and 18.2.0.
103
117
 
104
118
  Install, update, disable, or remove `paseo-omp` independently of the bundled provider. Verify the provider snapshot contains `omp-plugin` after installation; a bundled `omp` entry may remain present and is not modified by this plugin.
105
119
 
@@ -5,7 +5,8 @@ import {
5
5
  useRpc,
6
6
  useWorkspace,
7
7
  } from "@getpaseo/plugin/client";
8
- import { Icon, TextInput } from "@getpaseo/plugin/client/react-native";
8
+ import { copyText, Icon, TextInput } from "@getpaseo/plugin/client/react-native";
9
+ import { ExternalLink } from "@getpaseo/plugin/client/ui";
9
10
  import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
11
  import type { ReactNode } from "react";
11
12
  import { useCallback, useMemo, useState } from "react";
@@ -13,20 +14,24 @@ import type { TextStyle, ViewStyle } from "react-native";
13
14
  import { Pressable, ScrollView, Switch, Text, View } from "react-native";
14
15
  import type { ComposerPillSettings } from "../shared/composer-pill-settings";
15
16
  import { listOmpConfig, type OmpConfig } from "../shared/omp-config";
17
+ import { listOmpModels } from "../shared/omp-models";
16
18
  import {
17
19
  categorizeOmpSetting,
18
20
  formatOmpSettingLabel,
21
+ isOmpStructuredSettingPath,
19
22
  listOmpSettings,
20
23
  OMP_SETTING_CATEGORIES,
21
- type OmpScalarValue,
22
24
  type OmpSetting,
23
25
  type OmpSettingCategory,
26
+ parseOmpStructuredSettingValue,
24
27
  updateOmpSettings,
25
28
  } from "../shared/omp-settings";
26
29
  import { type OmpStore, storeLabel } from "../shared/omp-store";
27
30
  import { getOmpProviderHealth, type OmpProviderHealth } from "../shared/provider-diagnostics";
31
+ import { getOmpSupportReport, OMP_SUPPORT_ISSUE_URL } from "../shared/support-diagnostics";
28
32
  import { ComposerPillSettingsSection } from "./composer-pill-settings";
29
33
  import { openOmpExternalUrl } from "./external-url";
34
+ import { type OmpConfigSurfaceView as SurfaceView, surfaceViewsForScope } from "./omp-config-views";
30
35
  import {
31
36
  documentationForSettingCategory,
32
37
  documentationForSettingPath,
@@ -34,7 +39,9 @@ import {
34
39
  OMP_SETTINGS_REFERENCE,
35
40
  type OmpDocumentationLink,
36
41
  } from "./omp-doc-links";
42
+ import { normalizeOmpModels } from "./omp-model-picker-state";
37
43
  import { OmpPluginManagerSection } from "./omp-plugin-manager";
44
+ import { type OmpModelCatalogState, StructuredRoutingEditor } from "./omp-routing-editor";
38
45
  import { OmpStorePicker } from "./omp-store-picker";
39
46
  import { ompStoreKey } from "./omp-store-state";
40
47
  import {
@@ -56,6 +63,11 @@ import {
56
63
  summarizeProviderStatus,
57
64
  summarizeRpcUiSupport,
58
65
  } from "./provider-diagnostics-state";
66
+ import {
67
+ refreshSupportReport,
68
+ type SupportReportCopyState,
69
+ supportDiagnosticsViewState,
70
+ } from "./support-diagnostics-state";
59
71
 
60
72
  const SETTINGS_QUERY_KEY = ["paseo-omp", "settings"] as const;
61
73
  const CONFIG_POLL_MS = 30_000;
@@ -91,6 +103,10 @@ export interface OmpConfigStyles {
91
103
  docLinkPressed: ViewStyle;
92
104
  docLinkText: TextStyle;
93
105
  refreshLabel: TextStyle;
106
+ helpActions: ViewStyle;
107
+ helpReport: ViewStyle;
108
+ helpReportText: TextStyle;
109
+ helpLinkText: TextStyle;
94
110
  card: ViewStyle;
95
111
  cardHeader: ViewStyle;
96
112
  cardTitle: TextStyle;
@@ -215,6 +231,26 @@ function useConfigStyles(theme: PluginSurfaceProps["theme"], compact: boolean):
215
231
  backgroundColor: theme.colors.surface1,
216
232
  },
217
233
  refreshLabel: { color: theme.colors.foreground, fontSize: 13 },
234
+ helpActions: {
235
+ flexDirection: "row",
236
+ alignItems: "center",
237
+ flexWrap: "wrap",
238
+ gap: 8,
239
+ },
240
+ helpReport: {
241
+ maxWidth: "100%",
242
+ padding: compact ? 10 : 12,
243
+ borderWidth: 1,
244
+ borderColor: theme.colors.border,
245
+ borderRadius: 10,
246
+ backgroundColor: theme.colors.surface1,
247
+ },
248
+ helpReportText: {
249
+ color: theme.colors.foreground,
250
+ fontSize: compact ? 11 : 12,
251
+ lineHeight: compact ? 16 : 18,
252
+ },
253
+ helpLinkText: { color: theme.colors.accent, fontSize: 13, fontWeight: "600" },
218
254
  cardHeader: {
219
255
  flexDirection: "row",
220
256
  alignItems: "center",
@@ -691,6 +727,131 @@ function ProviderHealthSection({
691
727
  );
692
728
  }
693
729
 
730
+ function SupportDiagnosticsSection({
731
+ theme,
732
+ styles,
733
+ cwd,
734
+ store,
735
+ }: {
736
+ theme: PluginSurfaceProps["theme"];
737
+ styles: OmpConfigStyles;
738
+ cwd?: string;
739
+ store?: OmpStore;
740
+ }) {
741
+ const loadReport = useRpc(getOmpSupportReport);
742
+ const queryClient = useQueryClient();
743
+ const context = { store, ...(cwd ? { cwd } : {}) };
744
+ const queryKey = ["paseo-omp", "support-report", ompStoreKey(store), cwd ?? "global"];
745
+ const report = useQuery({
746
+ queryKey,
747
+ queryFn: () => loadReport(context),
748
+ staleTime: Number.POSITIVE_INFINITY,
749
+ retry: false,
750
+ });
751
+ const refreshReport = useMutation({
752
+ mutationFn: () => refreshSupportReport(loadReport, context),
753
+ onSuccess: (value) => queryClient.setQueryData(queryKey, value),
754
+ });
755
+ const [copyState, setCopyState] = useState<SupportReportCopyState>("idle");
756
+ const [linkError, setLinkError] = useState(false);
757
+ const viewState = supportDiagnosticsViewState({
758
+ loading: report.isLoading,
759
+ refreshing: refreshReport.isPending || (report.isFetching && !report.isLoading),
760
+ hasReport: Boolean(report.data?.report),
761
+ reportFailed: Boolean(report.error || refreshReport.error),
762
+ copyState,
763
+ });
764
+
765
+ const refresh = () => {
766
+ setCopyState("idle");
767
+ refreshReport.mutate();
768
+ };
769
+ const copyReport = async () => {
770
+ if (!report.data?.report) return;
771
+ setCopyState("copying");
772
+ try {
773
+ await copyText(report.data.report);
774
+ setCopyState("copied");
775
+ } catch {
776
+ setCopyState("error");
777
+ }
778
+ };
779
+
780
+ return (
781
+ <>
782
+ <View style={styles.sectionHeader}>
783
+ <Text style={styles.sectionTitle}>Support diagnostics</Text>
784
+ <Text style={styles.muted}>
785
+ Safe OMP runtime, compatibility, storage-state, and protocol counters for maintainers.
786
+ </Text>
787
+ </View>
788
+ <View style={styles.helpActions}>
789
+ <Pressable
790
+ accessibilityRole="button"
791
+ accessibilityLabel="Refresh OMP support report"
792
+ disabled={report.isFetching || refreshReport.isPending}
793
+ onPress={refresh}
794
+ style={styles.refresh}
795
+ >
796
+ <Icon name="RefreshCw" size={14} color={theme.colors.foreground} />
797
+ <Text style={styles.refreshLabel}>{viewState.refreshLabel}</Text>
798
+ </Pressable>
799
+ <Pressable
800
+ accessibilityRole="button"
801
+ accessibilityLabel="Copy OMP support report"
802
+ disabled={viewState.copyDisabled}
803
+ onPress={() => void copyReport()}
804
+ style={[styles.refresh, viewState.copyDisabled ? { opacity: 0.55 } : null]}
805
+ >
806
+ <Icon name="Copy" size={14} color={theme.colors.foreground} />
807
+ <Text style={styles.refreshLabel}>{viewState.copyLabel}</Text>
808
+ </Pressable>
809
+ <ExternalLink
810
+ href={OMP_SUPPORT_ISSUE_URL}
811
+ accessibilityLabel="Create paseo-omp GitHub issue"
812
+ onError={() => setLinkError(true)}
813
+ >
814
+ <Text style={styles.helpLinkText}>Create GitHub issue</Text>
815
+ </ExternalLink>
816
+ </View>
817
+ <Text style={styles.muted}>
818
+ Review before sharing. The report excludes prompts, transcripts, credentials, commands,
819
+ URLs, repository names, and private paths.
820
+ </Text>
821
+ {viewState.loadingMessage ? (
822
+ <Text accessibilityLiveRegion="polite" style={styles.muted}>
823
+ {viewState.loadingMessage}
824
+ </Text>
825
+ ) : null}
826
+ {viewState.reportError ? (
827
+ <Text accessibilityRole="alert" style={styles.error}>
828
+ {viewState.reportError}
829
+ </Text>
830
+ ) : null}
831
+ {viewState.copyFeedback ? (
832
+ <Text
833
+ accessibilityLiveRegion="polite"
834
+ style={copyState === "error" ? styles.error : styles.muted}
835
+ >
836
+ {viewState.copyFeedback}
837
+ </Text>
838
+ ) : null}
839
+ {linkError ? (
840
+ <Text accessibilityRole="alert" style={styles.error}>
841
+ Could not open GitHub. Copy the report and open the issue tracker manually.
842
+ </Text>
843
+ ) : null}
844
+ {report.data?.report ? (
845
+ <View style={styles.helpReport}>
846
+ <Text selectable style={styles.helpReportText}>
847
+ {report.data.report}
848
+ </Text>
849
+ </View>
850
+ ) : null}
851
+ </>
852
+ );
853
+ }
854
+
694
855
  const CATEGORY_LABELS: Record<OmpSettingCategory, string> = {
695
856
  appearance: "Appearance",
696
857
  model: "Model",
@@ -736,16 +897,6 @@ function fallbackSettingsFromConfig(config: OmpConfig | null | undefined): OmpSe
736
897
  return settings;
737
898
  }
738
899
 
739
- type SurfaceView = "overview" | "plugin" | "plugins" | "composer" | "configuration" | "diagnostics";
740
- const SURFACE_VIEWS: readonly { id: SurfaceView; label: string }[] = [
741
- { id: "overview", label: "Overview" },
742
- { id: "plugin", label: "Plugin" },
743
- { id: "plugins", label: "OMP plugins" },
744
- { id: "composer", label: "Composer" },
745
- { id: "configuration", label: "Configuration" },
746
- { id: "diagnostics", label: "Diagnostics" },
747
- ];
748
-
749
900
  function formatScalarValue(value: unknown): string {
750
901
  if (value === undefined || value === null) return "Not set";
751
902
  if (typeof value === "boolean") return value ? "Enabled" : "Disabled";
@@ -834,7 +985,7 @@ function StructuredSettingValue({
834
985
  );
835
986
  }
836
987
 
837
- type SettingDraft = { operation: "set"; value: string | boolean } | { operation: "reset" };
988
+ type SettingDraft = { operation: "set"; value: unknown } | { operation: "reset" };
838
989
 
839
990
  function EditableScalarValue({
840
991
  setting,
@@ -891,6 +1042,9 @@ function ConfigurationCategory({
891
1042
  styles,
892
1043
  settings,
893
1044
  drafts,
1045
+ theme,
1046
+ modelCatalog,
1047
+ modelRoles,
894
1048
  disabled,
895
1049
  workspaceScoped,
896
1050
  onDraft,
@@ -898,6 +1052,9 @@ function ConfigurationCategory({
898
1052
  }: {
899
1053
  category: { id: OmpSettingCategory; label: string };
900
1054
  styles: OmpConfigStyles;
1055
+ theme: PluginSurfaceProps["theme"];
1056
+ modelCatalog: OmpModelCatalogState;
1057
+ modelRoles: Readonly<Record<string, unknown>>;
901
1058
  settings: readonly OmpSetting[];
902
1059
  drafts: Readonly<Record<string, SettingDraft>>;
903
1060
  disabled: boolean;
@@ -926,7 +1083,12 @@ function ConfigurationCategory({
926
1083
  (setting.value !== null && typeof setting.value === "object");
927
1084
  const editable =
928
1085
  !setting.redacted && ["boolean", "number", "string", "enum"].includes(setting.type);
1086
+ const structuredEditable =
1087
+ !setting.redacted &&
1088
+ isOmpStructuredSettingPath(setting.path) &&
1089
+ setting.type === (setting.path === "cycleOrder" ? "array" : "record");
929
1090
  const settingDocumentation = documentationForSettingPath(setting.path);
1091
+ const draft = drafts[setting.path];
930
1092
  return (
931
1093
  <View key={setting.path} style={styles.setting}>
932
1094
  <View style={styles.settingHeader}>
@@ -934,7 +1096,7 @@ function ConfigurationCategory({
934
1096
  {setting.workspaceOverride ? (
935
1097
  <Text style={styles.source}>Workspace override</Text>
936
1098
  ) : null}
937
- {!complex && !editable ? (
1099
+ {!complex && !editable && !structuredEditable ? (
938
1100
  <StructuredSettingValue setting={setting} styles={styles} />
939
1101
  ) : null}
940
1102
  </View>
@@ -953,7 +1115,7 @@ function ConfigurationCategory({
953
1115
  {editable ? (
954
1116
  <EditableScalarValue
955
1117
  setting={setting}
956
- draft={drafts[setting.path]}
1118
+ draft={draft}
957
1119
  disabled={disabled}
958
1120
  resetLabel={workspaceScoped ? "Remove workspace override" : "Reset to default"}
959
1121
  showReset={!workspaceScoped || setting.workspaceOverride === true}
@@ -961,6 +1123,21 @@ function ConfigurationCategory({
961
1123
  onSet={(value) => onDraft(setting.path, { operation: "set", value })}
962
1124
  onReset={() => onDraft(setting.path, { operation: "reset" })}
963
1125
  />
1126
+ ) : structuredEditable ? (
1127
+ <StructuredRoutingEditor
1128
+ setting={setting}
1129
+ value={draft?.operation === "set" ? draft.value : setting.value}
1130
+ disabled={disabled}
1131
+ resetLabel={workspaceScoped ? "Remove workspace override" : "Reset to default"}
1132
+ showReset={!workspaceScoped || setting.workspaceOverride === true}
1133
+ resetPending={draft?.operation === "reset"}
1134
+ styles={styles}
1135
+ theme={theme}
1136
+ modelCatalog={modelCatalog}
1137
+ modelRoles={modelRoles}
1138
+ onSet={(value) => onDraft(setting.path, { operation: "set", value })}
1139
+ onReset={() => onDraft(setting.path, { operation: "reset" })}
1140
+ />
964
1141
  ) : complex ? (
965
1142
  <StructuredSettingValue setting={setting} styles={styles} />
966
1143
  ) : null}
@@ -1022,6 +1199,7 @@ function OmpConfigContent({
1022
1199
  const loadConfig = useRpc(listOmpConfig);
1023
1200
  const loadSettings = useRpc(listOmpSettings);
1024
1201
  const updateSettings = useRpc(updateOmpSettings);
1202
+ const loadModels = useRpc(listOmpModels);
1025
1203
  const queryClient = useQueryClient();
1026
1204
  const context = { store, ...(cwd ? { cwd } : {}) };
1027
1205
  const pendingMutations = useIsMutating({ mutationKey: ["paseo-omp"] });
@@ -1036,6 +1214,11 @@ function OmpConfigContent({
1036
1214
  queryFn: () => loadSettings(context),
1037
1215
  staleTime: Number.POSITIVE_INFINITY,
1038
1216
  });
1217
+ const modelsQuery = useQuery({
1218
+ queryKey: ["paseo-omp", "models", ompStoreKey(store), cwd ?? "global"],
1219
+ queryFn: () => loadModels(context),
1220
+ staleTime: 30_000,
1221
+ });
1039
1222
  const [view, setView] = useState<SurfaceView>("overview");
1040
1223
  const [activeCategory, setActiveCategory] = useState<OmpSettingCategory>("appearance");
1041
1224
  const [search, setSearch] = useState("");
@@ -1043,9 +1226,7 @@ function OmpConfigContent({
1043
1226
  const [documentationError, setDocumentationError] = useState<string | null>(null);
1044
1227
  const styles = useConfigStyles(theme, layout.compact);
1045
1228
  const normalizedSearch = search.trim().toLocaleLowerCase();
1046
- const surfaceViews = cwd
1047
- ? SURFACE_VIEWS.filter((candidate) => candidate.id !== "composer")
1048
- : SURFACE_VIEWS;
1229
+ const surfaceViews = surfaceViewsForScope(cwd !== undefined);
1049
1230
  const catalog = useMemo(() => {
1050
1231
  const sourceSettings = settingsQuery.data?.available
1051
1232
  ? settingsQuery.data.settings
@@ -1065,6 +1246,24 @@ function OmpConfigContent({
1065
1246
  }
1066
1247
  return { sourceSettings, matching, byCategory };
1067
1248
  }, [configQuery.data?.config, normalizedSearch, settingsQuery.data]);
1249
+ const modelCatalog: OmpModelCatalogState = {
1250
+ models: normalizeOmpModels(modelsQuery.data?.models ?? []),
1251
+ loading: modelsQuery.isLoading,
1252
+ ...(modelsQuery.error
1253
+ ? { error: "Could not list OMP models. Freeform selectors remain available." }
1254
+ : {}),
1255
+ };
1256
+ const modelRolesValue = drafts.modelRoles
1257
+ ? drafts.modelRoles.operation === "set"
1258
+ ? drafts.modelRoles.value
1259
+ : undefined
1260
+ : catalog.sourceSettings.find((setting) => setting.path === "modelRoles")?.value;
1261
+ const modelRoles =
1262
+ modelRolesValue !== null &&
1263
+ typeof modelRolesValue === "object" &&
1264
+ !Array.isArray(modelRolesValue)
1265
+ ? (modelRolesValue as Readonly<Record<string, unknown>>)
1266
+ : {};
1068
1267
  const visibleCategories = CONFIG_CATEGORIES.filter(
1069
1268
  (category) => (catalog.byCategory.get(category.id)?.length ?? 0) > 0,
1070
1269
  );
@@ -1089,8 +1288,12 @@ function OmpConfigContent({
1089
1288
  if (draft.operation === "reset") return { operation: "reset" as const, path };
1090
1289
  const setting = byPath.get(path);
1091
1290
  if (!setting) throw new Error(`Setting ${path} is no longer available.`);
1092
- let value: OmpScalarValue = draft.value;
1093
- if (setting.type === "number") {
1291
+ let value: unknown = draft.value;
1292
+ if (isOmpStructuredSettingPath(path)) {
1293
+ const parsed = parseOmpStructuredSettingValue(path, value);
1294
+ if (parsed === undefined) throw new Error(`${path} contains invalid routing values.`);
1295
+ value = parsed;
1296
+ } else if (setting.type === "number") {
1094
1297
  const raw = String(draft.value).trim();
1095
1298
  if (!raw) throw new Error(`${path} requires a number.`);
1096
1299
  const parsed = Number(raw);
@@ -1099,7 +1302,8 @@ function OmpConfigContent({
1099
1302
  }
1100
1303
  return { operation: "set" as const, path, value };
1101
1304
  });
1102
- return updateSettings({ ...context, revision, changes });
1305
+ const input = updateOmpSettings.input.parse({ ...context, revision, changes });
1306
+ return updateSettings(input);
1103
1307
  },
1104
1308
  onSuccess: (result) => {
1105
1309
  queryClient.setQueryData(settingsQueryKey, result.catalog);
@@ -1212,6 +1416,10 @@ function OmpConfigContent({
1212
1416
  <ProviderHealthSection theme={theme} styles={styles} cwd={cwd} store={store} />
1213
1417
  ) : null}
1214
1418
 
1419
+ {view === "help" ? (
1420
+ <SupportDiagnosticsSection theme={theme} styles={styles} cwd={cwd} store={store} />
1421
+ ) : null}
1422
+
1215
1423
  {view === "configuration" ? (
1216
1424
  <>
1217
1425
  <View style={styles.sectionHeader}>
@@ -1221,14 +1429,22 @@ function OmpConfigContent({
1221
1429
  accessibilityRole="button"
1222
1430
  accessibilityLabel="Refresh OMP configuration"
1223
1431
  style={styles.refresh}
1224
- disabled={configQuery.isFetching || settingsQuery.isFetching}
1432
+ disabled={
1433
+ configQuery.isFetching || settingsQuery.isFetching || modelsQuery.isFetching
1434
+ }
1225
1435
  onPress={() => {
1226
- void Promise.all([configQuery.refetch(), settingsQuery.refetch()]);
1436
+ void Promise.all([
1437
+ configQuery.refetch(),
1438
+ settingsQuery.refetch(),
1439
+ modelsQuery.refetch(),
1440
+ ]);
1227
1441
  }}
1228
1442
  >
1229
1443
  <Icon name="RefreshCw" size={14} color={theme.colors.foreground} />
1230
1444
  <Text style={styles.refreshLabel}>
1231
- {configQuery.isFetching || settingsQuery.isFetching ? "Refreshing…" : "Refresh"}
1445
+ {configQuery.isFetching || settingsQuery.isFetching || modelsQuery.isFetching
1446
+ ? "Refreshing…"
1447
+ : "Refresh"}
1232
1448
  </Text>
1233
1449
  </Pressable>
1234
1450
  </View>
@@ -1364,6 +1580,9 @@ function OmpConfigContent({
1364
1580
  {selectedCategory ? (
1365
1581
  <ConfigurationCategory
1366
1582
  category={selectedCategory}
1583
+ theme={theme}
1584
+ modelCatalog={modelCatalog}
1585
+ modelRoles={modelRoles}
1367
1586
  styles={styles}
1368
1587
  settings={catalog.byCategory.get(selectedCategory.id) ?? []}
1369
1588
  drafts={drafts}
@@ -0,0 +1,24 @@
1
+ export type OmpConfigSurfaceView =
2
+ | "overview"
3
+ | "plugin"
4
+ | "plugins"
5
+ | "composer"
6
+ | "configuration"
7
+ | "diagnostics"
8
+ | "help";
9
+
10
+ const SURFACE_VIEWS: readonly { id: OmpConfigSurfaceView; label: string }[] = [
11
+ { id: "overview", label: "Overview" },
12
+ { id: "plugin", label: "Plugin" },
13
+ { id: "plugins", label: "OMP plugins" },
14
+ { id: "composer", label: "Composer" },
15
+ { id: "configuration", label: "Configuration" },
16
+ { id: "diagnostics", label: "Diagnostics" },
17
+ { id: "help", label: "Help" },
18
+ ];
19
+
20
+ export function surfaceViewsForScope(workspaceScoped: boolean) {
21
+ return workspaceScoped
22
+ ? SURFACE_VIEWS.filter((candidate) => candidate.id !== "composer")
23
+ : SURFACE_VIEWS;
24
+ }
@@ -0,0 +1,145 @@
1
+ import type { OmpModelCandidate } from "../shared/omp-models";
2
+ import { isValidOmpModelSelector } from "../shared/omp-settings";
3
+
4
+ export const OMP_MODEL_PICKER_RESULT_LIMIT = 8;
5
+
6
+ export type OmpModelPickerModel = {
7
+ selector: string;
8
+ label: string;
9
+ provider: string;
10
+ reasoning: boolean;
11
+ imageInput: boolean;
12
+ contextWindow?: number;
13
+ thinkingLevels: readonly string[];
14
+ };
15
+
16
+ export type OmpModelPickerChoice =
17
+ | { kind: "alias"; selector: `@${string}`; label: string }
18
+ | { kind: "model"; model: OmpModelPickerModel; recommended: boolean };
19
+
20
+ const REASONING_ROLES: Readonly<Record<string, true>> = {
21
+ default: true,
22
+ task: true,
23
+ plan: true,
24
+ slow: true,
25
+ advisor: true,
26
+ reviewer: true,
27
+ };
28
+
29
+ export function roleCapabilityMatch(role: string, model: OmpModelPickerModel): boolean | undefined {
30
+ const normalizedRole = role.trim().toLocaleLowerCase();
31
+ if (normalizedRole === "vision") return model.imageInput;
32
+ if (REASONING_ROLES[normalizedRole]) return model.reasoning;
33
+ return undefined;
34
+ }
35
+
36
+ export function normalizeOmpModels(models: readonly OmpModelCandidate[]): OmpModelPickerModel[] {
37
+ return models.map((model) => ({
38
+ selector: model.selector,
39
+ label: model.name || model.id,
40
+ provider: model.provider,
41
+ reasoning: model.reasoning,
42
+ imageInput: model.input.some((input) => input.toLocaleLowerCase() === "image"),
43
+ ...(model.contextWindow === null || model.contextWindow === undefined
44
+ ? {}
45
+ : { contextWindow: model.contextWindow }),
46
+ thinkingLevels: model.thinkingLevels,
47
+ }));
48
+ }
49
+
50
+ export function aliasChoices(
51
+ modelRoles: Readonly<Record<string, unknown>>,
52
+ currentRole?: string,
53
+ ): OmpModelPickerChoice[] {
54
+ return Object.keys(modelRoles)
55
+ .filter((role) => role.length > 0 && role !== currentRole)
56
+ .sort((left, right) => left.localeCompare(right))
57
+ .map((role) => ({ kind: "alias" as const, selector: `@${role}` as const, label: role }));
58
+ }
59
+
60
+ function choiceSearchText(choice: OmpModelPickerChoice): string {
61
+ if (choice.kind === "alias") return `${choice.label} ${choice.selector}`;
62
+ const model = choice.model;
63
+ return [
64
+ model.label,
65
+ model.selector,
66
+ model.provider,
67
+ model.reasoning ? "reasoning" : "standard",
68
+ model.imageInput ? "image vision" : "text",
69
+ model.contextWindow?.toString() ?? "",
70
+ ...model.thinkingLevels,
71
+ ].join(" ");
72
+ }
73
+
74
+ export function filterModelChoices(
75
+ models: readonly OmpModelPickerModel[],
76
+ aliases: readonly OmpModelPickerChoice[],
77
+ query: string,
78
+ role: string,
79
+ limit = OMP_MODEL_PICKER_RESULT_LIMIT,
80
+ ): OmpModelPickerChoice[] {
81
+ const normalizedQuery = query.trim().toLocaleLowerCase();
82
+ const choices: OmpModelPickerChoice[] = [
83
+ ...aliases,
84
+ ...models.map((model) => ({
85
+ kind: "model" as const,
86
+ model,
87
+ recommended: roleCapabilityMatch(role, model) === true,
88
+ })),
89
+ ];
90
+ return choices
91
+ .filter(
92
+ (choice) =>
93
+ normalizedQuery.length === 0 ||
94
+ choiceSearchText(choice).toLocaleLowerCase().includes(normalizedQuery),
95
+ )
96
+ .sort((left, right) => {
97
+ const leftRecommended = left.kind === "model" && left.recommended;
98
+ const rightRecommended = right.kind === "model" && right.recommended;
99
+ if (leftRecommended !== rightRecommended) return leftRecommended ? -1 : 1;
100
+ if (left.kind !== right.kind) return left.kind === "alias" ? -1 : 1;
101
+ return choiceSearchText(left).localeCompare(choiceSearchText(right));
102
+ })
103
+ .slice(0, Math.max(0, limit));
104
+ }
105
+
106
+ export function formatModelSelector(selector: string, thinkingLevel?: string): string {
107
+ return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
108
+ }
109
+
110
+ export type OmpThinkingSelectorChoice = {
111
+ selector: string;
112
+ available: boolean;
113
+ };
114
+
115
+ export function thinkingSelectorChoice(
116
+ selector: string,
117
+ thinkingLevel: string,
118
+ ): OmpThinkingSelectorChoice {
119
+ const suffixedSelector = formatModelSelector(selector, thinkingLevel);
120
+ return {
121
+ selector: suffixedSelector,
122
+ available: isValidOmpModelSelector(suffixedSelector),
123
+ };
124
+ }
125
+
126
+ export function updatePickerValue(
127
+ currentValue: string | readonly string[],
128
+ selector: string,
129
+ mode: "replace" | "append",
130
+ ): string | string[] {
131
+ if (mode === "replace") return selector;
132
+ const current = typeof currentValue === "string" ? [currentValue] : [...currentValue];
133
+ if (current.includes(selector)) return current;
134
+ return [...current, selector];
135
+ }
136
+
137
+ export function commitPickerSelection(
138
+ disabled: boolean,
139
+ selector: string,
140
+ onSelect: (selector: string) => void,
141
+ ): boolean {
142
+ if (disabled) return false;
143
+ onSelect(selector);
144
+ return true;
145
+ }