@omercnet/paseo-omp 0.3.0-next.105.1 → 0.3.0-next.106.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.
- package/client/omp-config-surface.tsx +80 -10
- package/client/omp-model-picker-state.ts +145 -0
- package/client/omp-model-picker.tsx +282 -0
- package/client/omp-routing-editor.tsx +307 -0
- package/docs/configuration.md +1 -1
- package/docs/images/model-role-picker-compact.webp +0 -0
- package/docs/images/model-role-picker-desktop.webp +0 -0
- package/index.server.ts +3 -0
- package/package.json +1 -1
- package/server/omp-models.ts +59 -0
- package/server/omp-settings.ts +30 -20
- package/shared/omp-models.ts +49 -0
- package/shared/omp-settings.ts +227 -3
|
@@ -13,14 +13,16 @@ import type { TextStyle, ViewStyle } from "react-native";
|
|
|
13
13
|
import { Pressable, ScrollView, Switch, Text, View } from "react-native";
|
|
14
14
|
import type { ComposerPillSettings } from "../shared/composer-pill-settings";
|
|
15
15
|
import { listOmpConfig, type OmpConfig } from "../shared/omp-config";
|
|
16
|
+
import { listOmpModels } from "../shared/omp-models";
|
|
16
17
|
import {
|
|
17
18
|
categorizeOmpSetting,
|
|
18
19
|
formatOmpSettingLabel,
|
|
20
|
+
isOmpStructuredSettingPath,
|
|
19
21
|
listOmpSettings,
|
|
20
22
|
OMP_SETTING_CATEGORIES,
|
|
21
|
-
type OmpScalarValue,
|
|
22
23
|
type OmpSetting,
|
|
23
24
|
type OmpSettingCategory,
|
|
25
|
+
parseOmpStructuredSettingValue,
|
|
24
26
|
updateOmpSettings,
|
|
25
27
|
} from "../shared/omp-settings";
|
|
26
28
|
import { type OmpStore, storeLabel } from "../shared/omp-store";
|
|
@@ -34,7 +36,9 @@ import {
|
|
|
34
36
|
OMP_SETTINGS_REFERENCE,
|
|
35
37
|
type OmpDocumentationLink,
|
|
36
38
|
} from "./omp-doc-links";
|
|
39
|
+
import { normalizeOmpModels } from "./omp-model-picker-state";
|
|
37
40
|
import { OmpPluginManagerSection } from "./omp-plugin-manager";
|
|
41
|
+
import { type OmpModelCatalogState, StructuredRoutingEditor } from "./omp-routing-editor";
|
|
38
42
|
import { OmpStorePicker } from "./omp-store-picker";
|
|
39
43
|
import { ompStoreKey } from "./omp-store-state";
|
|
40
44
|
import {
|
|
@@ -834,7 +838,7 @@ function StructuredSettingValue({
|
|
|
834
838
|
);
|
|
835
839
|
}
|
|
836
840
|
|
|
837
|
-
type SettingDraft = { operation: "set"; value:
|
|
841
|
+
type SettingDraft = { operation: "set"; value: unknown } | { operation: "reset" };
|
|
838
842
|
|
|
839
843
|
function EditableScalarValue({
|
|
840
844
|
setting,
|
|
@@ -891,6 +895,9 @@ function ConfigurationCategory({
|
|
|
891
895
|
styles,
|
|
892
896
|
settings,
|
|
893
897
|
drafts,
|
|
898
|
+
theme,
|
|
899
|
+
modelCatalog,
|
|
900
|
+
modelRoles,
|
|
894
901
|
disabled,
|
|
895
902
|
workspaceScoped,
|
|
896
903
|
onDraft,
|
|
@@ -898,6 +905,9 @@ function ConfigurationCategory({
|
|
|
898
905
|
}: {
|
|
899
906
|
category: { id: OmpSettingCategory; label: string };
|
|
900
907
|
styles: OmpConfigStyles;
|
|
908
|
+
theme: PluginSurfaceProps["theme"];
|
|
909
|
+
modelCatalog: OmpModelCatalogState;
|
|
910
|
+
modelRoles: Readonly<Record<string, unknown>>;
|
|
901
911
|
settings: readonly OmpSetting[];
|
|
902
912
|
drafts: Readonly<Record<string, SettingDraft>>;
|
|
903
913
|
disabled: boolean;
|
|
@@ -926,7 +936,12 @@ function ConfigurationCategory({
|
|
|
926
936
|
(setting.value !== null && typeof setting.value === "object");
|
|
927
937
|
const editable =
|
|
928
938
|
!setting.redacted && ["boolean", "number", "string", "enum"].includes(setting.type);
|
|
939
|
+
const structuredEditable =
|
|
940
|
+
!setting.redacted &&
|
|
941
|
+
isOmpStructuredSettingPath(setting.path) &&
|
|
942
|
+
setting.type === (setting.path === "cycleOrder" ? "array" : "record");
|
|
929
943
|
const settingDocumentation = documentationForSettingPath(setting.path);
|
|
944
|
+
const draft = drafts[setting.path];
|
|
930
945
|
return (
|
|
931
946
|
<View key={setting.path} style={styles.setting}>
|
|
932
947
|
<View style={styles.settingHeader}>
|
|
@@ -934,7 +949,7 @@ function ConfigurationCategory({
|
|
|
934
949
|
{setting.workspaceOverride ? (
|
|
935
950
|
<Text style={styles.source}>Workspace override</Text>
|
|
936
951
|
) : null}
|
|
937
|
-
{!complex && !editable ? (
|
|
952
|
+
{!complex && !editable && !structuredEditable ? (
|
|
938
953
|
<StructuredSettingValue setting={setting} styles={styles} />
|
|
939
954
|
) : null}
|
|
940
955
|
</View>
|
|
@@ -953,7 +968,7 @@ function ConfigurationCategory({
|
|
|
953
968
|
{editable ? (
|
|
954
969
|
<EditableScalarValue
|
|
955
970
|
setting={setting}
|
|
956
|
-
draft={
|
|
971
|
+
draft={draft}
|
|
957
972
|
disabled={disabled}
|
|
958
973
|
resetLabel={workspaceScoped ? "Remove workspace override" : "Reset to default"}
|
|
959
974
|
showReset={!workspaceScoped || setting.workspaceOverride === true}
|
|
@@ -961,6 +976,21 @@ function ConfigurationCategory({
|
|
|
961
976
|
onSet={(value) => onDraft(setting.path, { operation: "set", value })}
|
|
962
977
|
onReset={() => onDraft(setting.path, { operation: "reset" })}
|
|
963
978
|
/>
|
|
979
|
+
) : structuredEditable ? (
|
|
980
|
+
<StructuredRoutingEditor
|
|
981
|
+
setting={setting}
|
|
982
|
+
value={draft?.operation === "set" ? draft.value : setting.value}
|
|
983
|
+
disabled={disabled}
|
|
984
|
+
resetLabel={workspaceScoped ? "Remove workspace override" : "Reset to default"}
|
|
985
|
+
showReset={!workspaceScoped || setting.workspaceOverride === true}
|
|
986
|
+
resetPending={draft?.operation === "reset"}
|
|
987
|
+
styles={styles}
|
|
988
|
+
theme={theme}
|
|
989
|
+
modelCatalog={modelCatalog}
|
|
990
|
+
modelRoles={modelRoles}
|
|
991
|
+
onSet={(value) => onDraft(setting.path, { operation: "set", value })}
|
|
992
|
+
onReset={() => onDraft(setting.path, { operation: "reset" })}
|
|
993
|
+
/>
|
|
964
994
|
) : complex ? (
|
|
965
995
|
<StructuredSettingValue setting={setting} styles={styles} />
|
|
966
996
|
) : null}
|
|
@@ -1022,6 +1052,7 @@ function OmpConfigContent({
|
|
|
1022
1052
|
const loadConfig = useRpc(listOmpConfig);
|
|
1023
1053
|
const loadSettings = useRpc(listOmpSettings);
|
|
1024
1054
|
const updateSettings = useRpc(updateOmpSettings);
|
|
1055
|
+
const loadModels = useRpc(listOmpModels);
|
|
1025
1056
|
const queryClient = useQueryClient();
|
|
1026
1057
|
const context = { store, ...(cwd ? { cwd } : {}) };
|
|
1027
1058
|
const pendingMutations = useIsMutating({ mutationKey: ["paseo-omp"] });
|
|
@@ -1036,6 +1067,11 @@ function OmpConfigContent({
|
|
|
1036
1067
|
queryFn: () => loadSettings(context),
|
|
1037
1068
|
staleTime: Number.POSITIVE_INFINITY,
|
|
1038
1069
|
});
|
|
1070
|
+
const modelsQuery = useQuery({
|
|
1071
|
+
queryKey: ["paseo-omp", "models", ompStoreKey(store), cwd ?? "global"],
|
|
1072
|
+
queryFn: () => loadModels(context),
|
|
1073
|
+
staleTime: 30_000,
|
|
1074
|
+
});
|
|
1039
1075
|
const [view, setView] = useState<SurfaceView>("overview");
|
|
1040
1076
|
const [activeCategory, setActiveCategory] = useState<OmpSettingCategory>("appearance");
|
|
1041
1077
|
const [search, setSearch] = useState("");
|
|
@@ -1065,6 +1101,24 @@ function OmpConfigContent({
|
|
|
1065
1101
|
}
|
|
1066
1102
|
return { sourceSettings, matching, byCategory };
|
|
1067
1103
|
}, [configQuery.data?.config, normalizedSearch, settingsQuery.data]);
|
|
1104
|
+
const modelCatalog: OmpModelCatalogState = {
|
|
1105
|
+
models: normalizeOmpModels(modelsQuery.data?.models ?? []),
|
|
1106
|
+
loading: modelsQuery.isLoading,
|
|
1107
|
+
...(modelsQuery.error
|
|
1108
|
+
? { error: "Could not list OMP models. Freeform selectors remain available." }
|
|
1109
|
+
: {}),
|
|
1110
|
+
};
|
|
1111
|
+
const modelRolesValue = drafts.modelRoles
|
|
1112
|
+
? drafts.modelRoles.operation === "set"
|
|
1113
|
+
? drafts.modelRoles.value
|
|
1114
|
+
: undefined
|
|
1115
|
+
: catalog.sourceSettings.find((setting) => setting.path === "modelRoles")?.value;
|
|
1116
|
+
const modelRoles =
|
|
1117
|
+
modelRolesValue !== null &&
|
|
1118
|
+
typeof modelRolesValue === "object" &&
|
|
1119
|
+
!Array.isArray(modelRolesValue)
|
|
1120
|
+
? (modelRolesValue as Readonly<Record<string, unknown>>)
|
|
1121
|
+
: {};
|
|
1068
1122
|
const visibleCategories = CONFIG_CATEGORIES.filter(
|
|
1069
1123
|
(category) => (catalog.byCategory.get(category.id)?.length ?? 0) > 0,
|
|
1070
1124
|
);
|
|
@@ -1089,8 +1143,12 @@ function OmpConfigContent({
|
|
|
1089
1143
|
if (draft.operation === "reset") return { operation: "reset" as const, path };
|
|
1090
1144
|
const setting = byPath.get(path);
|
|
1091
1145
|
if (!setting) throw new Error(`Setting ${path} is no longer available.`);
|
|
1092
|
-
let value:
|
|
1093
|
-
if (
|
|
1146
|
+
let value: unknown = draft.value;
|
|
1147
|
+
if (isOmpStructuredSettingPath(path)) {
|
|
1148
|
+
const parsed = parseOmpStructuredSettingValue(path, value);
|
|
1149
|
+
if (parsed === undefined) throw new Error(`${path} contains invalid routing values.`);
|
|
1150
|
+
value = parsed;
|
|
1151
|
+
} else if (setting.type === "number") {
|
|
1094
1152
|
const raw = String(draft.value).trim();
|
|
1095
1153
|
if (!raw) throw new Error(`${path} requires a number.`);
|
|
1096
1154
|
const parsed = Number(raw);
|
|
@@ -1099,7 +1157,8 @@ function OmpConfigContent({
|
|
|
1099
1157
|
}
|
|
1100
1158
|
return { operation: "set" as const, path, value };
|
|
1101
1159
|
});
|
|
1102
|
-
|
|
1160
|
+
const input = updateOmpSettings.input.parse({ ...context, revision, changes });
|
|
1161
|
+
return updateSettings(input);
|
|
1103
1162
|
},
|
|
1104
1163
|
onSuccess: (result) => {
|
|
1105
1164
|
queryClient.setQueryData(settingsQueryKey, result.catalog);
|
|
@@ -1221,14 +1280,22 @@ function OmpConfigContent({
|
|
|
1221
1280
|
accessibilityRole="button"
|
|
1222
1281
|
accessibilityLabel="Refresh OMP configuration"
|
|
1223
1282
|
style={styles.refresh}
|
|
1224
|
-
disabled={
|
|
1283
|
+
disabled={
|
|
1284
|
+
configQuery.isFetching || settingsQuery.isFetching || modelsQuery.isFetching
|
|
1285
|
+
}
|
|
1225
1286
|
onPress={() => {
|
|
1226
|
-
void Promise.all([
|
|
1287
|
+
void Promise.all([
|
|
1288
|
+
configQuery.refetch(),
|
|
1289
|
+
settingsQuery.refetch(),
|
|
1290
|
+
modelsQuery.refetch(),
|
|
1291
|
+
]);
|
|
1227
1292
|
}}
|
|
1228
1293
|
>
|
|
1229
1294
|
<Icon name="RefreshCw" size={14} color={theme.colors.foreground} />
|
|
1230
1295
|
<Text style={styles.refreshLabel}>
|
|
1231
|
-
{configQuery.isFetching || settingsQuery.isFetching
|
|
1296
|
+
{configQuery.isFetching || settingsQuery.isFetching || modelsQuery.isFetching
|
|
1297
|
+
? "Refreshing…"
|
|
1298
|
+
: "Refresh"}
|
|
1232
1299
|
</Text>
|
|
1233
1300
|
</Pressable>
|
|
1234
1301
|
</View>
|
|
@@ -1364,6 +1431,9 @@ function OmpConfigContent({
|
|
|
1364
1431
|
{selectedCategory ? (
|
|
1365
1432
|
<ConfigurationCategory
|
|
1366
1433
|
category={selectedCategory}
|
|
1434
|
+
theme={theme}
|
|
1435
|
+
modelCatalog={modelCatalog}
|
|
1436
|
+
modelRoles={modelRoles}
|
|
1367
1437
|
styles={styles}
|
|
1368
1438
|
settings={catalog.byCategory.get(selectedCategory.id) ?? []}
|
|
1369
1439
|
drafts={drafts}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { TextInput } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import type { TextStyle, ViewStyle } from "react-native";
|
|
5
|
+
import { Pressable, Text, View } from "react-native";
|
|
6
|
+
import {
|
|
7
|
+
commitPickerSelection,
|
|
8
|
+
filterModelChoices,
|
|
9
|
+
type OmpModelPickerChoice,
|
|
10
|
+
type OmpModelPickerModel,
|
|
11
|
+
thinkingSelectorChoice,
|
|
12
|
+
} from "./omp-model-picker-state";
|
|
13
|
+
|
|
14
|
+
type OmpModelPickerProps = {
|
|
15
|
+
theme: PluginSurfaceProps["theme"];
|
|
16
|
+
models: readonly OmpModelPickerModel[];
|
|
17
|
+
aliases: readonly OmpModelPickerChoice[];
|
|
18
|
+
role: string;
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
onSelect(selector: string): void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type OmpModelPickerStyles = {
|
|
26
|
+
root: ViewStyle;
|
|
27
|
+
trigger: ViewStyle;
|
|
28
|
+
triggerText: TextStyle;
|
|
29
|
+
panel: ViewStyle;
|
|
30
|
+
search: TextStyle;
|
|
31
|
+
status: TextStyle;
|
|
32
|
+
error: TextStyle;
|
|
33
|
+
result: ViewStyle;
|
|
34
|
+
resultPressed: ViewStyle;
|
|
35
|
+
titleRow: ViewStyle;
|
|
36
|
+
title: TextStyle;
|
|
37
|
+
selector: TextStyle;
|
|
38
|
+
metadata: TextStyle;
|
|
39
|
+
provenance: TextStyle;
|
|
40
|
+
thinkingRow: ViewStyle;
|
|
41
|
+
thinking: ViewStyle;
|
|
42
|
+
buttonDisabled: ViewStyle;
|
|
43
|
+
thinkingText: TextStyle;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function formatContextWindow(tokens?: number): string {
|
|
47
|
+
if (tokens === undefined) return "Context unknown";
|
|
48
|
+
if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M context`;
|
|
49
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K context`;
|
|
50
|
+
return `${tokens} context`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function OmpModelPicker({
|
|
54
|
+
theme,
|
|
55
|
+
models,
|
|
56
|
+
aliases,
|
|
57
|
+
role,
|
|
58
|
+
disabled,
|
|
59
|
+
loading,
|
|
60
|
+
error,
|
|
61
|
+
onSelect,
|
|
62
|
+
}: OmpModelPickerProps) {
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const [query, setQuery] = useState("");
|
|
65
|
+
const choices = useMemo(
|
|
66
|
+
() => filterModelChoices(models, aliases, query, role),
|
|
67
|
+
[aliases, models, query, role],
|
|
68
|
+
);
|
|
69
|
+
const styles = useMemo(
|
|
70
|
+
() => ({
|
|
71
|
+
root: { gap: 6, width: "100%" as const, maxWidth: 520 },
|
|
72
|
+
trigger: {
|
|
73
|
+
alignSelf: "flex-start" as const,
|
|
74
|
+
paddingHorizontal: 9,
|
|
75
|
+
paddingVertical: 6,
|
|
76
|
+
borderWidth: 1,
|
|
77
|
+
borderColor: open ? theme.colors.accent : theme.colors.border,
|
|
78
|
+
borderRadius: 7,
|
|
79
|
+
backgroundColor: theme.colors.surface1,
|
|
80
|
+
},
|
|
81
|
+
triggerText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" as const },
|
|
82
|
+
panel: {
|
|
83
|
+
gap: 6,
|
|
84
|
+
padding: 8,
|
|
85
|
+
borderWidth: 1,
|
|
86
|
+
borderColor: theme.colors.border,
|
|
87
|
+
borderRadius: 9,
|
|
88
|
+
backgroundColor: theme.colors.surface0,
|
|
89
|
+
},
|
|
90
|
+
search: {
|
|
91
|
+
color: theme.colors.foreground,
|
|
92
|
+
borderWidth: 1,
|
|
93
|
+
borderColor: theme.colors.border,
|
|
94
|
+
borderRadius: 7,
|
|
95
|
+
backgroundColor: theme.colors.surface1,
|
|
96
|
+
paddingHorizontal: 9,
|
|
97
|
+
paddingVertical: 6,
|
|
98
|
+
fontSize: 13,
|
|
99
|
+
},
|
|
100
|
+
status: { color: theme.colors.foregroundMuted, fontSize: 12 },
|
|
101
|
+
error: { color: theme.colors.statusDanger, fontSize: 12 },
|
|
102
|
+
result: {
|
|
103
|
+
gap: 3,
|
|
104
|
+
paddingHorizontal: 9,
|
|
105
|
+
paddingVertical: 7,
|
|
106
|
+
borderWidth: 1,
|
|
107
|
+
borderColor: theme.colors.border,
|
|
108
|
+
borderRadius: 7,
|
|
109
|
+
backgroundColor: theme.colors.surface1,
|
|
110
|
+
},
|
|
111
|
+
resultPressed: { borderColor: theme.colors.accent, backgroundColor: theme.colors.surface2 },
|
|
112
|
+
titleRow: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 6 },
|
|
113
|
+
title: { color: theme.colors.foreground, fontSize: 12, fontWeight: "700" as const },
|
|
114
|
+
selector: { color: theme.colors.foregroundMuted, fontSize: 11, fontFamily: "monospace" },
|
|
115
|
+
metadata: { color: theme.colors.foregroundMuted, fontSize: 11 },
|
|
116
|
+
provenance: { color: theme.colors.accent, fontSize: 11, fontWeight: "600" as const },
|
|
117
|
+
thinkingRow: {
|
|
118
|
+
flexDirection: "row" as const,
|
|
119
|
+
flexWrap: "wrap" as const,
|
|
120
|
+
gap: 5,
|
|
121
|
+
marginTop: 3,
|
|
122
|
+
},
|
|
123
|
+
thinking: {
|
|
124
|
+
paddingHorizontal: 7,
|
|
125
|
+
paddingVertical: 4,
|
|
126
|
+
borderWidth: 1,
|
|
127
|
+
borderColor: theme.colors.border,
|
|
128
|
+
borderRadius: 999,
|
|
129
|
+
backgroundColor: theme.colors.surface0,
|
|
130
|
+
},
|
|
131
|
+
buttonDisabled: { opacity: 0.45 },
|
|
132
|
+
thinkingText: { color: theme.colors.foreground, fontSize: 10, fontWeight: "600" as const },
|
|
133
|
+
}),
|
|
134
|
+
[open, theme],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const choose = (selector: string) => {
|
|
138
|
+
if (!commitPickerSelection(disabled, selector, onSelect)) return;
|
|
139
|
+
setOpen(false);
|
|
140
|
+
setQuery("");
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View style={styles.root}>
|
|
145
|
+
<Pressable
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel={`${open ? "Close" : "Open"} model picker for ${role || "this row"}`}
|
|
148
|
+
accessibilityState={{ disabled, expanded: open }}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
onPress={() => setOpen((current) => !current)}
|
|
151
|
+
style={styles.trigger}
|
|
152
|
+
>
|
|
153
|
+
<Text style={styles.triggerText}>{open ? "Close models" : "Choose model"}</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
{open ? (
|
|
156
|
+
<View accessibilityLabel={`Model choices for ${role || "this row"}`} style={styles.panel}>
|
|
157
|
+
<TextInput
|
|
158
|
+
accessibilityLabel="Search available OMP models"
|
|
159
|
+
autoFocus
|
|
160
|
+
accessibilityRole="combobox"
|
|
161
|
+
accessibilityState={{ expanded: true }}
|
|
162
|
+
editable={!disabled}
|
|
163
|
+
placeholder="Search provider, model, selector, or thinking level"
|
|
164
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
165
|
+
value={query}
|
|
166
|
+
onChangeText={setQuery}
|
|
167
|
+
onKeyPress={(event) => {
|
|
168
|
+
if (event.nativeEvent.key === "Escape") setOpen(false);
|
|
169
|
+
}}
|
|
170
|
+
style={styles.search}
|
|
171
|
+
/>
|
|
172
|
+
{loading ? <Text style={styles.status}>Loading OMP models…</Text> : null}
|
|
173
|
+
{error ? (
|
|
174
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
175
|
+
{error}
|
|
176
|
+
</Text>
|
|
177
|
+
) : null}
|
|
178
|
+
{!loading && !error && models.length === 0 && aliases.length > 0 ? (
|
|
179
|
+
<Text style={styles.status}>
|
|
180
|
+
OMP reported no concrete models. Configured role aliases remain available.
|
|
181
|
+
</Text>
|
|
182
|
+
) : null}
|
|
183
|
+
{!loading && choices.length === 0 ? (
|
|
184
|
+
<Text style={styles.status}>No models or role aliases match this search.</Text>
|
|
185
|
+
) : null}
|
|
186
|
+
{choices.map((choice) =>
|
|
187
|
+
choice.kind === "alias" ? (
|
|
188
|
+
<Pressable
|
|
189
|
+
key={choice.selector}
|
|
190
|
+
accessibilityRole="button"
|
|
191
|
+
accessibilityLabel={`Select role alias ${choice.selector}`}
|
|
192
|
+
accessibilityState={{ disabled }}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
onPress={() => choose(choice.selector)}
|
|
195
|
+
style={({ pressed }) => [styles.result, pressed ? styles.resultPressed : null]}
|
|
196
|
+
>
|
|
197
|
+
<Text style={styles.title}>Role alias · {choice.label}</Text>
|
|
198
|
+
<Text style={styles.selector}>{choice.selector}</Text>
|
|
199
|
+
</Pressable>
|
|
200
|
+
) : (
|
|
201
|
+
<ModelResult
|
|
202
|
+
key={choice.model.selector}
|
|
203
|
+
choice={choice}
|
|
204
|
+
styles={styles}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
onSelect={choose}
|
|
207
|
+
/>
|
|
208
|
+
),
|
|
209
|
+
)}
|
|
210
|
+
</View>
|
|
211
|
+
) : null}
|
|
212
|
+
</View>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ModelResult({
|
|
217
|
+
choice,
|
|
218
|
+
styles,
|
|
219
|
+
disabled,
|
|
220
|
+
onSelect,
|
|
221
|
+
}: {
|
|
222
|
+
choice: Extract<OmpModelPickerChoice, { kind: "model" }>;
|
|
223
|
+
styles: OmpModelPickerStyles;
|
|
224
|
+
onSelect(selector: string): void;
|
|
225
|
+
disabled: boolean;
|
|
226
|
+
}) {
|
|
227
|
+
const { model } = choice;
|
|
228
|
+
const capabilityText = [
|
|
229
|
+
model.reasoning ? "Reasoning" : "Standard",
|
|
230
|
+
model.imageInput ? "Image input" : "Text input",
|
|
231
|
+
formatContextWindow(model.contextWindow),
|
|
232
|
+
].join(" · ");
|
|
233
|
+
return (
|
|
234
|
+
<View style={styles.result}>
|
|
235
|
+
<Pressable
|
|
236
|
+
accessibilityRole="button"
|
|
237
|
+
accessibilityLabel={`Select ${model.label}, ${model.selector}`}
|
|
238
|
+
accessibilityState={{ disabled }}
|
|
239
|
+
disabled={disabled}
|
|
240
|
+
onPress={() => onSelect(model.selector)}
|
|
241
|
+
style={({ pressed }) => (pressed ? styles.resultPressed : undefined)}
|
|
242
|
+
>
|
|
243
|
+
<View style={styles.titleRow}>
|
|
244
|
+
<Text style={styles.title}>{model.label}</Text>
|
|
245
|
+
<Text style={styles.metadata}>{model.provider}</Text>
|
|
246
|
+
</View>
|
|
247
|
+
<Text style={styles.selector}>{model.selector}</Text>
|
|
248
|
+
<Text style={styles.metadata}>{capabilityText}</Text>
|
|
249
|
+
{choice.recommended ? (
|
|
250
|
+
<Text style={styles.provenance}>Matches this role’s capabilities</Text>
|
|
251
|
+
) : null}
|
|
252
|
+
</Pressable>
|
|
253
|
+
{model.thinkingLevels.length > 0 ? (
|
|
254
|
+
<View accessibilityLabel={`Thinking levels for ${model.label}`} style={styles.thinkingRow}>
|
|
255
|
+
{model.thinkingLevels.map((level) => {
|
|
256
|
+
const thinkingChoice = thinkingSelectorChoice(model.selector, level);
|
|
257
|
+
const thinkingDisabled = disabled || !thinkingChoice.available;
|
|
258
|
+
return (
|
|
259
|
+
<Pressable
|
|
260
|
+
key={level}
|
|
261
|
+
accessibilityRole="button"
|
|
262
|
+
accessibilityLabel={
|
|
263
|
+
thinkingChoice.available
|
|
264
|
+
? `Select ${model.label} with ${level} thinking`
|
|
265
|
+
: `${level} thinking unavailable: selector exceeds 513 UTF-8 bytes`
|
|
266
|
+
}
|
|
267
|
+
accessibilityState={{ disabled: thinkingDisabled }}
|
|
268
|
+
disabled={thinkingDisabled}
|
|
269
|
+
onPress={() => onSelect(thinkingChoice.selector)}
|
|
270
|
+
style={[styles.thinking, thinkingDisabled ? styles.buttonDisabled : null]}
|
|
271
|
+
>
|
|
272
|
+
<Text style={styles.thinkingText}>
|
|
273
|
+
{thinkingChoice.available ? level : `${level} · too long`}
|
|
274
|
+
</Text>
|
|
275
|
+
</Pressable>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
</View>
|
|
279
|
+
) : null}
|
|
280
|
+
</View>
|
|
281
|
+
);
|
|
282
|
+
}
|