@omercnet/paseo-omp 0.2.1 → 0.3.0-next.91.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 (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +6 -2
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +213 -9
  41. package/server/provider/host-tools.ts +71 -0
  42. package/server/provider/omp-rpc.ts +82 -15
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/session-descriptors.ts +306 -1
  46. package/server/provider/session.ts +704 -249
  47. package/server/provider/subsessions.ts +4 -1
  48. package/server/provider/timeline-projector.ts +70 -33
  49. package/server/provider-diagnostics.ts +122 -36
  50. package/server/quota.ts +3 -2
  51. package/server/sessions.ts +2 -2
  52. package/shared/composer-pill-settings.ts +28 -0
  53. package/shared/external-url.ts +21 -0
  54. package/shared/hub.ts +3 -3
  55. package/shared/mcp.ts +47 -0
  56. package/shared/memory.ts +2 -1
  57. package/shared/omp-config.ts +5 -1
  58. package/shared/omp-plugins.ts +74 -33
  59. package/shared/omp-settings.ts +8 -1
  60. package/shared/omp-store.ts +58 -0
  61. package/shared/provider-diagnostics.ts +12 -3
  62. package/shared/quota.ts +2 -1
  63. package/shared/sessions.ts +2 -1
@@ -0,0 +1,155 @@
1
+ import { type PluginButtonContentProps, useAgent, usePaseo } from "@getpaseo/plugin/client";
2
+ import { TextInput } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { buildOmpMcpServerCommand, type OmpMcpServerAction } from "../shared/mcp";
6
+
7
+ import { isOmpPluginProvider } from "./omp-store-state";
8
+
9
+ const GENERAL_ACTIONS = [
10
+ { label: "List servers", command: "/mcp list" },
11
+ { label: "Add server", command: "/mcp add" },
12
+ { label: "Reload", command: "/mcp reload" },
13
+ { label: "Resources", command: "/mcp resources" },
14
+ { label: "Prompts", command: "/mcp prompts" },
15
+ { label: "Notifications", command: "/mcp notifications" },
16
+ ] as const;
17
+
18
+ const SERVER_ACTIONS: ReadonlyArray<{ label: string; action: OmpMcpServerAction }> = [
19
+ { label: "Test", action: "test" },
20
+ { label: "Authorize", action: "reauth" },
21
+ { label: "Enable", action: "enable" },
22
+ { label: "Disable", action: "disable" },
23
+ ];
24
+
25
+ export function McpPopover(props: PluginButtonContentProps) {
26
+ const { theme, layout, close } = props;
27
+ const agentId = props.context === "agent" ? props.agentId : "";
28
+ const agent = useAgent(agentId, ({ provider, status }) => ({ provider, status }));
29
+ const paseo = usePaseo();
30
+ const [serverName, setServerName] = useState("");
31
+ const [pendingCommand, setPendingCommand] = useState<string | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const styles = useMemo(
34
+ () => ({
35
+ root: { gap: layout.compact ? 10 : 12, width: layout.compact ? undefined : 340 },
36
+ title: { color: theme.colors.foreground, fontSize: 14, fontWeight: "700" as const },
37
+ muted: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
38
+ error: { color: theme.colors.statusDanger, fontSize: 12 },
39
+ actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 7 },
40
+ action: {
41
+ paddingHorizontal: 10,
42
+ paddingVertical: 8,
43
+ borderWidth: 1,
44
+ borderColor: theme.colors.border,
45
+ borderRadius: 8,
46
+ backgroundColor: theme.colors.surface1,
47
+ },
48
+ actionPressed: { opacity: 0.72 },
49
+ actionDisabled: { opacity: 0.45 },
50
+ actionText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" as const },
51
+ input: {
52
+ color: theme.colors.foreground,
53
+ borderWidth: 1,
54
+ borderColor: theme.colors.border,
55
+ borderRadius: 8,
56
+ backgroundColor: theme.colors.surface0,
57
+ paddingHorizontal: 10,
58
+ paddingVertical: 8,
59
+ fontSize: 13,
60
+ },
61
+ }),
62
+ [layout.compact, theme],
63
+ );
64
+
65
+ const targetCommands = SERVER_ACTIONS.map(({ label, action }) => ({
66
+ label,
67
+ command: buildOmpMcpServerCommand(action, serverName),
68
+ }));
69
+ const unavailable = !isOmpPluginProvider(agent?.provider);
70
+
71
+ async function send(command: string): Promise<void> {
72
+ setPendingCommand(command);
73
+ setError(null);
74
+ try {
75
+ await paseo.agents.ref(agentId).send(command);
76
+ close();
77
+ } catch (cause) {
78
+ setError(cause instanceof Error ? cause.message : "Could not send the OMP MCP command.");
79
+ } finally {
80
+ setPendingCommand(null);
81
+ }
82
+ }
83
+
84
+ if (unavailable)
85
+ return <Text style={styles.muted}>MCP controls require an OMP Plugin agent.</Text>;
86
+
87
+ return (
88
+ <View style={styles.root}>
89
+ <Text style={styles.title}>OMP MCP</Text>
90
+ <Text style={styles.muted}>
91
+ Commands run in this OMP session. Results, setup questions, and authorization stay in the
92
+ chat timeline so they remain usable from remote and mobile clients.
93
+ </Text>
94
+ <View style={styles.actions}>
95
+ {GENERAL_ACTIONS.map((action) => (
96
+ <Pressable
97
+ key={action.command}
98
+ accessibilityRole="button"
99
+ accessibilityLabel={action.label}
100
+ disabled={pendingCommand !== null}
101
+ onPress={() => void send(action.command)}
102
+ style={({ pressed }) => [
103
+ styles.action,
104
+ pressed ? styles.actionPressed : null,
105
+ pendingCommand !== null ? styles.actionDisabled : null,
106
+ ]}
107
+ >
108
+ <Text style={styles.actionText}>
109
+ {pendingCommand === action.command ? "Sending…" : action.label}
110
+ </Text>
111
+ </Pressable>
112
+ ))}
113
+ </View>
114
+ <TextInput
115
+ accessibilityLabel="OMP MCP server name"
116
+ autoCapitalize="none"
117
+ autoCorrect={false}
118
+ onChangeText={setServerName}
119
+ placeholder="Server name"
120
+ placeholderTextColor={theme.colors.foregroundMuted}
121
+ style={styles.input}
122
+ value={serverName}
123
+ />
124
+ <View style={styles.actions}>
125
+ {targetCommands.map((action) => (
126
+ <Pressable
127
+ key={action.label}
128
+ accessibilityRole="button"
129
+ accessibilityLabel={`${action.label} MCP server`}
130
+ disabled={!action.command || pendingCommand !== null}
131
+ onPress={() => action.command && void send(action.command)}
132
+ style={({ pressed }) => [
133
+ styles.action,
134
+ pressed ? styles.actionPressed : null,
135
+ !action.command || pendingCommand !== null ? styles.actionDisabled : null,
136
+ ]}
137
+ >
138
+ <Text style={styles.actionText}>
139
+ {pendingCommand === action.command ? "Sending…" : action.label}
140
+ </Text>
141
+ </Pressable>
142
+ ))}
143
+ </View>
144
+ {agent?.status === "running" ? (
145
+ <Text style={styles.muted}>The command may wait until the active turn finishes.</Text>
146
+ ) : null}
147
+ {serverName.trim() && targetCommands.every((action) => !action.command) ? (
148
+ <Text style={styles.error}>
149
+ Server names may contain letters, numbers, dash, underscore, dot, and colon.
150
+ </Text>
151
+ ) : null}
152
+ {error ? <Text style={styles.error}>{error}</Text> : null}
153
+ </View>
154
+ );
155
+ }
@@ -1,18 +1,22 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
2
  import { useRpc, useWorkspace } from "@getpaseo/plugin/client";
3
3
  import { useQuery } from "@tanstack/react-query";
4
- import { useMemo } from "react";
4
+ import { useMemo, useState } from "react";
5
5
  import { ScrollView, Text, View } from "react-native";
6
6
  import { listOmpMemory } from "../shared/memory";
7
+ import type { OmpStore } from "../shared/omp-store";
8
+ import { OmpStorePicker } from "./omp-store-picker";
9
+ import { ompStoreKey } from "./omp-store-state";
7
10
 
8
11
  const MEMORY_POLL_MS = 15_000;
9
12
 
10
13
  export function OmpMemoryPanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
11
14
  const directory = useWorkspace(workspaceId, (workspace) => workspace.directory) ?? "";
15
+ const [store, setStore] = useState<OmpStore>();
12
16
  const loadMemory = useRpc(listOmpMemory);
13
17
  const memory = useQuery({
14
- queryKey: ["paseo-omp", "memory", directory],
15
- queryFn: () => loadMemory({ cwd: directory }),
18
+ queryKey: ["paseo-omp", "memory", ompStoreKey(store), directory],
19
+ queryFn: () => loadMemory({ cwd: directory, store }),
16
20
  enabled: directory.length > 0,
17
21
  refetchInterval: MEMORY_POLL_MS,
18
22
  });
@@ -53,6 +57,7 @@ export function OmpMemoryPanel({ theme, layout, workspaceId }: PluginWorkspacePa
53
57
  {memory.data?.bank ? `Bank: ${memory.data.bank}` : "Retained workspace facts"}
54
58
  </Text>
55
59
  </View>
60
+ <OmpStorePicker theme={theme} store={store} onChange={setStore} />
56
61
  {memory.isLoading ? <Text style={styles.subtitle}>Loading retained facts…</Text> : null}
57
62
  {memory.error ? <Text style={styles.error}>Could not read workspace memory.</Text> : null}
58
63
  {!memory.isLoading && !memory.error && (memory.data?.facts.length ?? 0) === 0 ? (
@@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query";
3
3
  import { useMemo } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import { listOmpMemory } from "../shared/memory";
6
+ import { storeForProvider, storeLabel } from "../shared/omp-store";
7
+ import { ompStoreKey } from "./omp-store-state";
6
8
 
7
9
  const MEMORY_POLL_MS = 15_000;
8
10
  const PREVIEW_LIMIT = 20;
@@ -10,11 +12,13 @@ const PREVIEW_LIMIT = 20;
10
12
  export function MemoryPopover(props: PluginButtonContentProps) {
11
13
  const { theme, layout } = props;
12
14
  const agentId = props.context === "agent" ? props.agentId : "";
13
- const cwd = useAgent(agentId, (agent) => agent.cwd) ?? "";
15
+ const agent = useAgent(agentId, ({ cwd, provider }) => ({ cwd, provider }));
16
+ const cwd = agent?.cwd ?? "";
17
+ const store = storeForProvider(agent?.provider);
14
18
  const loadMemory = useRpc(listOmpMemory);
15
19
  const memory = useQuery({
16
- queryKey: ["paseo-omp", "memory", cwd],
17
- queryFn: () => loadMemory({ cwd }),
20
+ queryKey: ["paseo-omp", "memory", ompStoreKey(store), cwd],
21
+ queryFn: () => loadMemory({ cwd, store }),
18
22
  enabled: cwd.length > 0,
19
23
  refetchInterval: MEMORY_POLL_MS,
20
24
  });
@@ -50,7 +54,7 @@ export function MemoryPopover(props: PluginButtonContentProps) {
50
54
  return (
51
55
  <View style={styles.root}>
52
56
  <View style={styles.header}>
53
- <Text style={styles.title}>OMP Memory</Text>
57
+ <Text style={styles.title}>OMP Memory · {storeLabel(store)}</Text>
54
58
  <Text style={styles.muted}>{facts.length} facts</Text>
55
59
  </View>
56
60
  <Text style={styles.muted}>{memory.data?.bank ?? "Workspace memory"}</Text>
@@ -1,10 +1,17 @@
1
- import { type PluginSurfaceProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
1
+ import {
2
+ type PluginSurfaceProps,
3
+ type PluginWorkspacePanelProps,
4
+ usePaseo,
5
+ useRpc,
6
+ useWorkspace,
7
+ } from "@getpaseo/plugin/client";
2
8
  import { Icon, TextInput } from "@getpaseo/plugin/client/react-native";
3
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
10
  import type { ReactNode } from "react";
5
11
  import { useCallback, useMemo, useState } from "react";
6
12
  import type { TextStyle, ViewStyle } from "react-native";
7
- import { Linking, Pressable, ScrollView, Switch, Text, View } from "react-native";
13
+ import { Pressable, ScrollView, Switch, Text, View } from "react-native";
14
+ import type { ComposerPillSettings } from "../shared/composer-pill-settings";
8
15
  import { listOmpConfig, type OmpConfig } from "../shared/omp-config";
9
16
  import {
10
17
  categorizeOmpSetting,
@@ -16,7 +23,10 @@ import {
16
23
  type OmpSettingCategory,
17
24
  updateOmpSettings,
18
25
  } from "../shared/omp-settings";
26
+ import { type OmpStore, storeLabel } from "../shared/omp-store";
19
27
  import { getOmpProviderHealth, type OmpProviderHealth } from "../shared/provider-diagnostics";
28
+ import { ComposerPillSettingsSection } from "./composer-pill-settings";
29
+ import { openOmpExternalUrl } from "./external-url";
20
30
  import {
21
31
  documentationForSettingCategory,
22
32
  documentationForSettingPath,
@@ -25,6 +35,8 @@ import {
25
35
  type OmpDocumentationLink,
26
36
  } from "./omp-doc-links";
27
37
  import { OmpPluginManagerSection } from "./omp-plugin-manager";
38
+ import { OmpStorePicker } from "./omp-store-picker";
39
+ import { ompStoreKey } from "./omp-store-state";
28
40
  import {
29
41
  type BinaryHealthSummary,
30
42
  loadReadyProviderSnapshot,
@@ -560,7 +572,7 @@ function ProcessSection({
560
572
  health: OmpProviderHealth;
561
573
  }) {
562
574
  return (
563
- <SectionCard styles={styles} title="Processes">
575
+ <SectionCard styles={styles} title="Hub metadata">
564
576
  <KeyValueRow
565
577
  styles={styles}
566
578
  label="OMP Hub"
@@ -574,16 +586,20 @@ function ProcessSection({
574
586
  function ProviderHealthSection({
575
587
  theme,
576
588
  styles,
589
+ cwd,
590
+ store,
577
591
  }: {
578
592
  theme: PluginSurfaceProps["theme"];
579
593
  styles: OmpConfigStyles;
594
+ cwd?: string;
595
+ store?: OmpStore;
580
596
  }) {
581
597
  const paseo = usePaseo();
582
598
  const queryClient = useQueryClient();
583
599
  const loadHealth = useRpc(getOmpProviderHealth);
584
600
  const health = useQuery({
585
- queryKey: HEALTH_QUERY_KEY,
586
- queryFn: () => loadHealth({}),
601
+ queryKey: [...HEALTH_QUERY_KEY, ompStoreKey(store), cwd ?? "global"],
602
+ queryFn: () => loadHealth({ store, ...(cwd ? { cwd } : {}) }),
587
603
  });
588
604
  const providers = useQuery({
589
605
  queryKey: PROVIDERS_QUERY_KEY,
@@ -593,8 +609,16 @@ function ProviderHealthSection({
593
609
  mutationFn: async () => {
594
610
  const result = await refreshProviderDiagnostics({
595
611
  providers: paseo.providers,
596
- loadForcedHealth: () => loadHealth({ force: true }),
597
- cacheHealth: (value) => queryClient.setQueryData(HEALTH_QUERY_KEY, value),
612
+ providerIds: [
613
+ ...selectKnownOmpProviders(providers.data?.entries ?? []).map((provider) => provider.id),
614
+ ...(store?.profile ? [`omp-plugin-${store.profile}`] : []),
615
+ ],
616
+ loadForcedHealth: () => loadHealth({ store, force: true, ...(cwd ? { cwd } : {}) }),
617
+ cacheHealth: (value) =>
618
+ queryClient.setQueryData(
619
+ [...HEALTH_QUERY_KEY, ompStoreKey(store), cwd ?? "global"],
620
+ value,
621
+ ),
598
622
  cacheProviders: (value) => queryClient.setQueryData(PROVIDERS_QUERY_KEY, value),
599
623
  });
600
624
  if (result.failed) throw new Error("Could not fully refresh OMP provider health.");
@@ -621,8 +645,9 @@ function ProviderHealthSection({
621
645
  </View>
622
646
  </View>
623
647
  <Text style={styles.muted}>
624
- These checks use the daemon&apos;s global OMP command and default storage, not per-agent
625
- profile overrides.
648
+ Binary probes run from {cwd ? "this workspace" : "the daemon working directory"}.
649
+ Configuration, storage, MCP, and databases use {storeLabel(store)}. Hub metadata remains
650
+ shared across profiles.
626
651
  </Text>
627
652
 
628
653
  {health.isLoading ? <Text style={styles.muted}>Checking the omp installation…</Text> : null}
@@ -711,11 +736,12 @@ function fallbackSettingsFromConfig(config: OmpConfig | null | undefined): OmpSe
711
736
  return settings;
712
737
  }
713
738
 
714
- type SurfaceView = "overview" | "plugin" | "plugins" | "configuration" | "diagnostics";
739
+ type SurfaceView = "overview" | "plugin" | "plugins" | "composer" | "configuration" | "diagnostics";
715
740
  const SURFACE_VIEWS: readonly { id: SurfaceView; label: string }[] = [
716
741
  { id: "overview", label: "Overview" },
717
742
  { id: "plugin", label: "Plugin" },
718
743
  { id: "plugins", label: "OMP plugins" },
744
+ { id: "composer", label: "Composer" },
719
745
  { id: "configuration", label: "Configuration" },
720
746
  { id: "diagnostics", label: "Diagnostics" },
721
747
  ];
@@ -814,6 +840,8 @@ function EditableScalarValue({
814
840
  setting,
815
841
  draft,
816
842
  disabled,
843
+ resetLabel,
844
+ showReset,
817
845
  styles,
818
846
  onSet,
819
847
  onReset,
@@ -821,6 +849,8 @@ function EditableScalarValue({
821
849
  setting: OmpSetting;
822
850
  draft: SettingDraft | undefined;
823
851
  disabled: boolean;
852
+ resetLabel: string;
853
+ showReset: boolean;
824
854
  styles: OmpConfigStyles;
825
855
  onSet(value: string | boolean): void;
826
856
  onReset(): void;
@@ -829,7 +859,7 @@ function EditableScalarValue({
829
859
  return (
830
860
  <View style={styles.recordList}>
831
861
  {draft?.operation === "reset" ? (
832
- <Text style={styles.muted}>Will reset to the OMP default</Text>
862
+ <Text style={styles.muted}>{resetLabel} when changes are applied</Text>
833
863
  ) : setting.type === "boolean" ? (
834
864
  <Switch
835
865
  accessibilityLabel={`Toggle ${formatOmpSettingLabel(setting.path)}`}
@@ -847,9 +877,11 @@ function EditableScalarValue({
847
877
  style={styles.scalarInput}
848
878
  />
849
879
  )}
850
- <Pressable accessibilityRole="button" disabled={disabled} onPress={onReset}>
851
- <Text style={styles.resetAction}>Reset to default</Text>
852
- </Pressable>
880
+ {showReset ? (
881
+ <Pressable accessibilityRole="button" disabled={disabled} onPress={onReset}>
882
+ <Text style={styles.resetAction}>{resetLabel}</Text>
883
+ </Pressable>
884
+ ) : null}
853
885
  </View>
854
886
  );
855
887
  }
@@ -860,6 +892,7 @@ function ConfigurationCategory({
860
892
  settings,
861
893
  drafts,
862
894
  disabled,
895
+ workspaceScoped,
863
896
  onDraft,
864
897
  onOpenDocumentation,
865
898
  }: {
@@ -868,6 +901,7 @@ function ConfigurationCategory({
868
901
  settings: readonly OmpSetting[];
869
902
  drafts: Readonly<Record<string, SettingDraft>>;
870
903
  disabled: boolean;
904
+ workspaceScoped: boolean;
871
905
  onDraft(path: string, draft: SettingDraft): void;
872
906
  onOpenDocumentation(link: OmpDocumentationLink): void;
873
907
  }) {
@@ -897,6 +931,9 @@ function ConfigurationCategory({
897
931
  <View key={setting.path} style={styles.setting}>
898
932
  <View style={styles.settingHeader}>
899
933
  <Text style={styles.cardTitle}>{formatOmpSettingLabel(setting.path)}</Text>
934
+ {setting.workspaceOverride ? (
935
+ <Text style={styles.source}>Workspace override</Text>
936
+ ) : null}
900
937
  {!complex && !editable ? (
901
938
  <StructuredSettingValue setting={setting} styles={styles} />
902
939
  ) : null}
@@ -918,6 +955,8 @@ function ConfigurationCategory({
918
955
  setting={setting}
919
956
  draft={drafts[setting.path]}
920
957
  disabled={disabled}
958
+ resetLabel={workspaceScoped ? "Remove workspace override" : "Reset to default"}
959
+ showReset={!workspaceScoped || setting.workspaceOverride === true}
921
960
  styles={styles}
922
961
  onSet={(value) => onDraft(setting.path, { operation: "set", value })}
923
962
  onReset={() => onDraft(setting.path, { operation: "reset" })}
@@ -938,15 +977,17 @@ function ConfigurationCategory({
938
977
  function SurfaceTabs({
939
978
  styles,
940
979
  selected,
980
+ views,
941
981
  onSelect,
942
982
  }: {
943
983
  styles: OmpConfigStyles;
944
984
  selected: SurfaceView;
985
+ views: readonly { id: SurfaceView; label: string }[];
945
986
  onSelect: (view: SurfaceView) => void;
946
987
  }) {
947
988
  return (
948
989
  <View accessibilityRole="tablist" style={styles.topTabs}>
949
- {SURFACE_VIEWS.map((view) => {
990
+ {views.map((view) => {
950
991
  const active = selected === view.id;
951
992
  return (
952
993
  <Pressable
@@ -965,19 +1006,34 @@ function SurfaceTabs({
965
1006
  </View>
966
1007
  );
967
1008
  }
968
- export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1009
+ function OmpConfigContent({
1010
+ theme,
1011
+ layout,
1012
+ cwd,
1013
+ store,
1014
+ onStoreChange,
1015
+ onComposerPillSettingsChange,
1016
+ }: PluginSurfaceProps & {
1017
+ cwd?: string;
1018
+ store?: OmpStore;
1019
+ onStoreChange(store: OmpStore | undefined): void;
1020
+ onComposerPillSettingsChange?: (settings: ComposerPillSettings) => void;
1021
+ }) {
969
1022
  const loadConfig = useRpc(listOmpConfig);
970
1023
  const loadSettings = useRpc(listOmpSettings);
971
1024
  const updateSettings = useRpc(updateOmpSettings);
972
1025
  const queryClient = useQueryClient();
1026
+ const context = { store, ...(cwd ? { cwd } : {}) };
1027
+ const pendingMutations = useIsMutating({ mutationKey: ["paseo-omp"] });
973
1028
  const configQuery = useQuery({
974
- queryKey: ["paseo-omp", "config"],
975
- queryFn: () => loadConfig({}),
1029
+ queryKey: ["paseo-omp", "config", ompStoreKey(store), cwd ?? "global"],
1030
+ queryFn: () => loadConfig(context),
976
1031
  refetchInterval: CONFIG_POLL_MS,
977
1032
  });
1033
+ const settingsQueryKey = [...SETTINGS_QUERY_KEY, ompStoreKey(store), cwd ?? "global"];
978
1034
  const settingsQuery = useQuery({
979
- queryKey: SETTINGS_QUERY_KEY,
980
- queryFn: () => loadSettings({}),
1035
+ queryKey: settingsQueryKey,
1036
+ queryFn: () => loadSettings(context),
981
1037
  staleTime: Number.POSITIVE_INFINITY,
982
1038
  });
983
1039
  const [view, setView] = useState<SurfaceView>("overview");
@@ -987,6 +1043,9 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
987
1043
  const [documentationError, setDocumentationError] = useState<string | null>(null);
988
1044
  const styles = useConfigStyles(theme, layout.compact);
989
1045
  const normalizedSearch = search.trim().toLocaleLowerCase();
1046
+ const surfaceViews = cwd
1047
+ ? SURFACE_VIEWS.filter((candidate) => candidate.id !== "composer")
1048
+ : SURFACE_VIEWS;
990
1049
  const catalog = useMemo(() => {
991
1050
  const sourceSettings = settingsQuery.data?.available
992
1051
  ? settingsQuery.data.settings
@@ -1014,13 +1073,14 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1014
1073
  const openDocumentation = useCallback(async (link: OmpDocumentationLink) => {
1015
1074
  setDocumentationError(null);
1016
1075
  try {
1017
- await Linking.openURL(link.url);
1076
+ await openOmpExternalUrl(link.url);
1018
1077
  } catch {
1019
1078
  setDocumentationError(`Could not open ${link.label.toLocaleLowerCase()}.`);
1020
1079
  }
1021
1080
  }, []);
1022
1081
 
1023
1082
  const save = useMutation({
1083
+ mutationKey: ["paseo-omp", "settings", ompStoreKey(store)],
1024
1084
  mutationFn: async () => {
1025
1085
  const revision = settingsQuery.data?.revision;
1026
1086
  if (!revision) throw new Error("OMP settings cannot be edited without a current revision.");
@@ -1039,10 +1099,12 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1039
1099
  }
1040
1100
  return { operation: "set" as const, path, value };
1041
1101
  });
1042
- return updateSettings({ revision, changes });
1102
+ return updateSettings({ ...context, revision, changes });
1043
1103
  },
1044
1104
  onSuccess: (result) => {
1045
- queryClient.setQueryData(SETTINGS_QUERY_KEY, result.catalog);
1105
+ queryClient.setQueryData(settingsQueryKey, result.catalog);
1106
+ void queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
1107
+ void queryClient.invalidateQueries({ queryKey: ["paseo-omp", "config"] });
1046
1108
  if (
1047
1109
  !result.conflict &&
1048
1110
  !result.failed &&
@@ -1059,14 +1121,35 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1059
1121
  },
1060
1122
  });
1061
1123
  const draftCount = Object.keys(drafts).length;
1124
+ const workspaceOverrideCount = catalog.sourceSettings.filter(
1125
+ (setting) => setting.workspaceOverride,
1126
+ ).length;
1062
1127
  const displayedConfigPath = settingsQuery.data?.available
1063
1128
  ? settingsQuery.data.path
1064
1129
  : configQuery.data?.path;
1065
1130
 
1066
1131
  return (
1067
1132
  <ScrollView contentContainerStyle={styles.root}>
1068
- <Text style={styles.pageTitle}>OMP</Text>
1069
- <SurfaceTabs styles={styles} selected={view} onSelect={setView} />
1133
+ <Text style={styles.pageTitle}>{cwd ? "Workspace OMP" : "OMP"}</Text>
1134
+ {cwd ? (
1135
+ <Text selectable style={styles.muted}>
1136
+ Project-scoped view · {cwd}
1137
+ </Text>
1138
+ ) : null}
1139
+ {view !== "composer" ? (
1140
+ <>
1141
+ <OmpStorePicker
1142
+ theme={theme}
1143
+ store={store}
1144
+ onChange={onStoreChange}
1145
+ disabled={pendingMutations > 0}
1146
+ />
1147
+ <Text style={styles.muted}>
1148
+ Switching stores clears unapplied edits and pending confirmations.
1149
+ </Text>
1150
+ </>
1151
+ ) : null}
1152
+ <SurfaceTabs styles={styles} selected={view} views={surfaceViews} onSelect={setView} />
1070
1153
 
1071
1154
  {view === "overview" ? (
1072
1155
  <>
@@ -1078,12 +1161,29 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1078
1161
  Could not read the native OMP configuration.
1079
1162
  </Text>
1080
1163
  ) : (
1081
- <SectionCard styles={styles} title="Native configuration">
1164
+ <SectionCard
1165
+ styles={styles}
1166
+ title={cwd ? "Workspace configuration" : "Native configuration"}
1167
+ >
1082
1168
  <KeyValueRow
1083
1169
  styles={styles}
1084
1170
  label="Source"
1085
1171
  value={displayedConfigPath ?? "Source unavailable"}
1086
1172
  />
1173
+ {cwd ? (
1174
+ <>
1175
+ <KeyValueRow styles={styles} label="Scope" value="Workspace / project" />
1176
+ <KeyValueRow
1177
+ styles={styles}
1178
+ label="Overrides"
1179
+ value={`${workspaceOverrideCount} project-specific settings`}
1180
+ />
1181
+ <Text style={styles.muted}>
1182
+ Settings without a workspace override inherit their effective global or default
1183
+ value.
1184
+ </Text>
1185
+ </>
1186
+ ) : null}
1087
1187
  <KeyValueRow
1088
1188
  styles={styles}
1089
1189
  label="Status"
@@ -1102,10 +1202,15 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1102
1202
  {view === "plugin" ? <PluginConfigurationSection styles={styles} /> : null}
1103
1203
 
1104
1204
  {view === "plugins" ? (
1105
- <OmpPluginManagerSection theme={theme} compact={layout.compact} />
1205
+ <OmpPluginManagerSection theme={theme} compact={layout.compact} cwd={cwd} store={store} />
1206
+ ) : null}
1207
+ {view === "composer" && !cwd && onComposerPillSettingsChange ? (
1208
+ <ComposerPillSettingsSection theme={theme} onChange={onComposerPillSettingsChange} />
1106
1209
  ) : null}
1107
1210
 
1108
- {view === "diagnostics" ? <ProviderHealthSection theme={theme} styles={styles} /> : null}
1211
+ {view === "diagnostics" ? (
1212
+ <ProviderHealthSection theme={theme} styles={styles} cwd={cwd} store={store} />
1213
+ ) : null}
1109
1214
 
1110
1215
  {view === "configuration" ? (
1111
1216
  <>
@@ -1147,6 +1252,13 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1147
1252
  ) : (
1148
1253
  <Text style={styles.source}>Source unavailable</Text>
1149
1254
  )}
1255
+ {cwd ? (
1256
+ <Text style={styles.muted}>
1257
+ {workspaceOverrideCount} project-specific overrides. All other effective values
1258
+ inherit global configuration or OMP defaults. Applying a change creates or updates
1259
+ the override in .omp/config.yml; removing an override restores inheritance.
1260
+ </Text>
1261
+ ) : null}
1150
1262
  </View>
1151
1263
  {documentationError ? (
1152
1264
  <Text accessibilityRole="alert" style={styles.error}>
@@ -1256,6 +1368,7 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1256
1368
  settings={catalog.byCategory.get(selectedCategory.id) ?? []}
1257
1369
  drafts={drafts}
1258
1370
  disabled={!settingsQuery.data?.revision || save.isPending}
1371
+ workspaceScoped={cwd !== undefined}
1259
1372
  onDraft={(path, draft) =>
1260
1373
  setDrafts((current) => ({ ...current, [path]: draft }))
1261
1374
  }
@@ -1272,3 +1385,50 @@ export function OmpConfigSurface({ theme, layout }: PluginSurfaceProps) {
1272
1385
  </ScrollView>
1273
1386
  );
1274
1387
  }
1388
+
1389
+ function OmpStoreContent(
1390
+ props: PluginSurfaceProps & {
1391
+ cwd?: string;
1392
+ onComposerPillSettingsChange?: (settings: ComposerPillSettings) => void;
1393
+ },
1394
+ ) {
1395
+ const [store, setStore] = useState<OmpStore>();
1396
+ // Remount every editor when its target changes: drafts, confirmations, and mutation notices
1397
+ // belong to one store/workspace and must never be applied to the next selection.
1398
+ return (
1399
+ <OmpConfigContent
1400
+ key={`${ompStoreKey(store)}:${props.cwd ?? "global"}`}
1401
+ {...props}
1402
+ store={store}
1403
+ onStoreChange={setStore}
1404
+ />
1405
+ );
1406
+ }
1407
+
1408
+ export function OmpConfigSurface(
1409
+ props: PluginSurfaceProps & {
1410
+ onComposerPillSettingsChange: (settings: ComposerPillSettings) => void;
1411
+ },
1412
+ ) {
1413
+ return <OmpStoreContent {...props} />;
1414
+ }
1415
+
1416
+ export function OmpWorkspacePanel(props: PluginWorkspacePanelProps) {
1417
+ const cwd = useWorkspace(props.workspaceId, (workspace) => workspace.directory);
1418
+ if (!cwd) {
1419
+ return (
1420
+ <View
1421
+ style={{
1422
+ flex: 1,
1423
+ padding: props.layout.compact ? 16 : 24,
1424
+ backgroundColor: props.theme.colors.surface0,
1425
+ }}
1426
+ >
1427
+ <Text style={{ color: props.theme.colors.foregroundMuted }}>
1428
+ Loading workspace OMP settings…
1429
+ </Text>
1430
+ </View>
1431
+ );
1432
+ }
1433
+ return <OmpStoreContent {...props} cwd={cwd} />;
1434
+ }