@omercnet/paseo-omp 0.3.0-next.104.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.
@@ -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: string | boolean } | { operation: "reset" };
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={drafts[setting.path]}
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: OmpScalarValue = draft.value;
1093
- if (setting.type === "number") {
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
- return updateSettings({ ...context, revision, changes });
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={configQuery.isFetching || settingsQuery.isFetching}
1283
+ disabled={
1284
+ configQuery.isFetching || settingsQuery.isFetching || modelsQuery.isFetching
1285
+ }
1225
1286
  onPress={() => {
1226
- void Promise.all([configQuery.refetch(), settingsQuery.refetch()]);
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 ? "Refreshing…" : "Refresh"}
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
+ }