paseo-bm-plugin 0.0.0-placeholder.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup view of the Beads Manager surface (delta 20260916-setup-screen):
|
|
3
|
+
* additional instructions per role with a full preview, the agent skills with
|
|
4
|
+
* a Test button, and the beads tools `br` / `bv` with a confirmed Install.
|
|
5
|
+
* Above the instructions, "Roles & models" (delta 20260921 §4.3.1) shows each
|
|
6
|
+
* role's provider, model, thinking and mode, with an Edit form that saves
|
|
7
|
+
* through `roles.save-settings`, and under a role whose fallback chain is
|
|
8
|
+
* offered, its policy and entries, saved through `roles.save-fallback` (§4.4.3).
|
|
9
|
+
*
|
|
10
|
+
* It is the surface's MAIN screen (owner decision Q1, delta 20260918e §4.2):
|
|
11
|
+
* no back button, and a "Workspaces" button to the workspace list. The surface
|
|
12
|
+
* hands it the status strip, so what a slash command reported is seen here.
|
|
13
|
+
*
|
|
14
|
+
* Wording lives in `setup-model.ts`. Client rules: React Native primitives
|
|
15
|
+
* only, colours from the theme, no Node import, no `server/` import.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
18
|
+
import { copyText } from "@getpaseo/plugin/client/react-native";
|
|
19
|
+
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
20
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
21
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
|
22
|
+
import {
|
|
23
|
+
rolesInstructionsRpc,
|
|
24
|
+
rolesOptionsRpc,
|
|
25
|
+
rolesSaveExtraRpc,
|
|
26
|
+
rolesSaveFallbackRpc,
|
|
27
|
+
rolesSaveSettingsRpc,
|
|
28
|
+
rolesSettingsRpc,
|
|
29
|
+
setupInstallToolRpc,
|
|
30
|
+
setupStatusRpc,
|
|
31
|
+
type BmRole,
|
|
32
|
+
type FallbackEntryInput,
|
|
33
|
+
type FallbackSettings,
|
|
34
|
+
type RoleSetting,
|
|
35
|
+
type RolesOptions,
|
|
36
|
+
type RolesSettings,
|
|
37
|
+
type SetupStatus,
|
|
38
|
+
} from "../shared/contracts";
|
|
39
|
+
import { dashboardStyles, toneColor, type Badge } from "./dashboard-model";
|
|
40
|
+
import { errorMessageOf } from "./launch-manager";
|
|
41
|
+
import { MarkdownView } from "./markdown-view";
|
|
42
|
+
import {
|
|
43
|
+
FALLBACK_POLICY_CHOICES,
|
|
44
|
+
ROLES_APPLY_NOTICE,
|
|
45
|
+
SETUP_ROLES,
|
|
46
|
+
addFallback,
|
|
47
|
+
applySavedRole,
|
|
48
|
+
canAddFallback,
|
|
49
|
+
entryAsSetting,
|
|
50
|
+
entryOfDraft,
|
|
51
|
+
fallbackBlocks,
|
|
52
|
+
fallbackDraftChanged,
|
|
53
|
+
fallbackDraftOf,
|
|
54
|
+
fallbackEntryText,
|
|
55
|
+
fallbackPolicyWarning,
|
|
56
|
+
fallbackPriceText,
|
|
57
|
+
moveFallback,
|
|
58
|
+
removeFallback,
|
|
59
|
+
replaceFallback,
|
|
60
|
+
saveFallbackInput,
|
|
61
|
+
extraCounter,
|
|
62
|
+
installWarning,
|
|
63
|
+
isSettingsConflict,
|
|
64
|
+
providerLabel,
|
|
65
|
+
roleDraftOf,
|
|
66
|
+
roleFormView,
|
|
67
|
+
roleRows,
|
|
68
|
+
rowOptionProviders,
|
|
69
|
+
saveErrorText,
|
|
70
|
+
saveSettingsInput,
|
|
71
|
+
savedNotes,
|
|
72
|
+
setupHeadline,
|
|
73
|
+
paseoToolsWarnings,
|
|
74
|
+
skillChips,
|
|
75
|
+
skillDirsText,
|
|
76
|
+
toolBadge,
|
|
77
|
+
type FallbackDraft,
|
|
78
|
+
type RoleChoice,
|
|
79
|
+
type RoleDraft,
|
|
80
|
+
type SetupRole,
|
|
81
|
+
} from "./setup-model";
|
|
82
|
+
import { Chip, RoleMark, type Styles, type Theme } from "./ui";
|
|
83
|
+
import { PLUGIN_VERSION } from "../shared/version";
|
|
84
|
+
|
|
85
|
+
export interface SetupScreenProps extends PluginSurfaceProps {
|
|
86
|
+
onOpenWorkspaces: () => void;
|
|
87
|
+
/** The surface's status strip (slash-command notice, launch state), drawn under the header. */
|
|
88
|
+
status?: ReactNode;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function CopyButton({ text, styles }: { text: string; styles: Styles }) {
|
|
92
|
+
const [copied, setCopied] = useState(false);
|
|
93
|
+
return (
|
|
94
|
+
<Pressable
|
|
95
|
+
accessibilityRole="button"
|
|
96
|
+
onPress={() => {
|
|
97
|
+
void copyText(text).then(
|
|
98
|
+
() => {
|
|
99
|
+
setCopied(true);
|
|
100
|
+
setTimeout(() => setCopied(false), 2000);
|
|
101
|
+
},
|
|
102
|
+
() => setCopied(false),
|
|
103
|
+
);
|
|
104
|
+
}}
|
|
105
|
+
style={styles.secondaryButton}
|
|
106
|
+
>
|
|
107
|
+
<Text style={styles.secondaryButtonText}>{copied ? "Copied" : "Copy"}</Text>
|
|
108
|
+
</Pressable>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function CommandLine({ label, command, styles, theme }: { label: string; command: string; styles: Styles; theme: Theme }) {
|
|
113
|
+
return (
|
|
114
|
+
<View style={{ gap: 4 }}>
|
|
115
|
+
<Text style={styles.body}>{label}</Text>
|
|
116
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
117
|
+
<Text
|
|
118
|
+
style={[styles.mono, { flex: 1, backgroundColor: theme.colors.surface0, padding: 6, borderRadius: 6 }]}
|
|
119
|
+
selectable
|
|
120
|
+
>
|
|
121
|
+
{command}
|
|
122
|
+
</Text>
|
|
123
|
+
<CopyButton text={command} styles={styles} />
|
|
124
|
+
</View>
|
|
125
|
+
</View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function RoleCard({ role, label, mark, styles, theme, compact }: {
|
|
130
|
+
role: SetupRole;
|
|
131
|
+
label: string;
|
|
132
|
+
mark: (typeof SETUP_ROLES)[number]["mark"];
|
|
133
|
+
styles: Styles;
|
|
134
|
+
theme: Theme;
|
|
135
|
+
compact: boolean;
|
|
136
|
+
}) {
|
|
137
|
+
const getInstructions = useRpc(rolesInstructionsRpc);
|
|
138
|
+
const saveExtra = useRpc(rolesSaveExtraRpc);
|
|
139
|
+
const queryClient = useQueryClient();
|
|
140
|
+
const instructions = useQuery({
|
|
141
|
+
queryKey: ["paseo-bm", "setup", "instructions", role],
|
|
142
|
+
queryFn: () => getInstructions({ role }),
|
|
143
|
+
});
|
|
144
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
145
|
+
const [preview, setPreview] = useState(false);
|
|
146
|
+
const [saving, setSaving] = useState(false);
|
|
147
|
+
const [note, setNote] = useState<{ text: string; tone: "success" | "danger" } | null>(null);
|
|
148
|
+
|
|
149
|
+
const data = instructions.data;
|
|
150
|
+
const text = draft ?? data?.extra ?? "";
|
|
151
|
+
const dirty = data !== undefined && draft !== null && draft !== data.extra;
|
|
152
|
+
|
|
153
|
+
const save = async () => {
|
|
154
|
+
setSaving(true);
|
|
155
|
+
setNote(null);
|
|
156
|
+
try {
|
|
157
|
+
await saveExtra({ role, text });
|
|
158
|
+
setDraft(null);
|
|
159
|
+
await queryClient.invalidateQueries({ queryKey: ["paseo-bm", "setup"] });
|
|
160
|
+
setNote({ text: `Saved. ${label} agents created from now on use it.`, tone: "success" });
|
|
161
|
+
} catch (failure) {
|
|
162
|
+
setNote({ text: errorMessageOf(failure), tone: "danger" });
|
|
163
|
+
} finally {
|
|
164
|
+
setSaving(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<View style={[styles.card, { gap: 6 }]}>
|
|
170
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
171
|
+
<RoleMark kind={mark} theme={theme} />
|
|
172
|
+
<Text style={[styles.sectionTitle, { flex: 1 }]}>{label}</Text>
|
|
173
|
+
{data === undefined ? null : <Text style={styles.body}>{extraCounter(text.length, data.maxChars)}</Text>}
|
|
174
|
+
</View>
|
|
175
|
+
{instructions.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
|
|
176
|
+
{instructions.isError ? (
|
|
177
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(instructions.error)}</Text>
|
|
178
|
+
) : null}
|
|
179
|
+
<TextInput
|
|
180
|
+
value={text}
|
|
181
|
+
onChangeText={setDraft}
|
|
182
|
+
multiline
|
|
183
|
+
placeholder={`Additional instructions for every new ${label}. They are added after the built-in rules and cannot override them.`}
|
|
184
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
185
|
+
style={[styles.mono, { minHeight: 72, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8 }]}
|
|
186
|
+
/>
|
|
187
|
+
<View style={styles.chipRow}>
|
|
188
|
+
<Pressable
|
|
189
|
+
accessibilityRole="button"
|
|
190
|
+
disabled={!dirty || saving}
|
|
191
|
+
onPress={() => void save()}
|
|
192
|
+
style={[styles.button, !dirty || saving ? { opacity: 0.5 } : null]}
|
|
193
|
+
>
|
|
194
|
+
<Text style={styles.buttonText}>{saving ? "Saving…" : "Save"}</Text>
|
|
195
|
+
</Pressable>
|
|
196
|
+
{dirty ? (
|
|
197
|
+
<Pressable accessibilityRole="button" onPress={() => setDraft(null)} style={styles.secondaryButton}>
|
|
198
|
+
<Text style={styles.secondaryButtonText}>Discard</Text>
|
|
199
|
+
</Pressable>
|
|
200
|
+
) : null}
|
|
201
|
+
<Pressable accessibilityRole="button" accessibilityState={{ expanded: preview }} onPress={() => setPreview(!preview)} style={styles.secondaryButton}>
|
|
202
|
+
<Text style={styles.secondaryButtonText}>{preview ? "Hide preview" : "Preview full instructions"}</Text>
|
|
203
|
+
</Pressable>
|
|
204
|
+
</View>
|
|
205
|
+
{note === null ? null : <Text style={[styles.body, { color: toneColor(theme, note.tone) }]}>{note.text}</Text>}
|
|
206
|
+
{preview && data !== undefined ? (
|
|
207
|
+
<View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 4 }]}>
|
|
208
|
+
<Text style={styles.body}>
|
|
209
|
+
{dirty ? "Showing the saved version; save to preview your changes." : `Built-in ${label} instructions plus your additions.`}
|
|
210
|
+
</Text>
|
|
211
|
+
<MarkdownView source={data.full} theme={theme} compact={compact} />
|
|
212
|
+
</View>
|
|
213
|
+
) : null}
|
|
214
|
+
</View>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ROLES_SETTINGS_KEY = ["paseo-bm", "setup", "roles-settings"] as const;
|
|
219
|
+
const roleOptionsKey = (provider: string) => ["paseo-bm", "setup", "role-options", provider] as const;
|
|
220
|
+
|
|
221
|
+
/** One field of the Edit form: a title and a row of chips, the selected one marked. */
|
|
222
|
+
function ChoiceField({ title, choices, selected, onSelect, styles, theme }: {
|
|
223
|
+
title: string;
|
|
224
|
+
choices: RoleChoice[];
|
|
225
|
+
selected: string | null;
|
|
226
|
+
onSelect: (id: string | null) => void;
|
|
227
|
+
styles: Styles;
|
|
228
|
+
theme: Theme;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<View style={{ gap: 4 }}>
|
|
232
|
+
<Text style={styles.body}>{title}</Text>
|
|
233
|
+
<View style={styles.chipRow}>
|
|
234
|
+
{choices.map((choice) => {
|
|
235
|
+
const on = choice.id === selected;
|
|
236
|
+
return (
|
|
237
|
+
<Chip
|
|
238
|
+
key={choice.id === null ? "(not set)" : `id:${choice.id}`}
|
|
239
|
+
badge={{ text: choice.label, tone: on ? "info" : "muted" }}
|
|
240
|
+
selected={on}
|
|
241
|
+
onPress={() => onSelect(choice.id)}
|
|
242
|
+
styles={styles}
|
|
243
|
+
theme={theme}
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</View>
|
|
248
|
+
</View>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The Edit form of one role (design §4.3.1). `revision` is the one the form
|
|
254
|
+
* was opened with: a save against a configuration changed since then is
|
|
255
|
+
* refused with `E_ROLE_SETTINGS_CONFLICT`, and only reopening takes the new one.
|
|
256
|
+
*/
|
|
257
|
+
function RoleEditForm({ role, label, setting, available, revision, styles, theme, onClose, onSaved }: {
|
|
258
|
+
role: BmRole;
|
|
259
|
+
label: string;
|
|
260
|
+
setting: RoleSetting;
|
|
261
|
+
available: readonly string[];
|
|
262
|
+
revision: string;
|
|
263
|
+
styles: Styles;
|
|
264
|
+
theme: Theme;
|
|
265
|
+
onClose: () => void;
|
|
266
|
+
onSaved: (notes: Badge[]) => void;
|
|
267
|
+
}) {
|
|
268
|
+
const getOptions = useRpc(rolesOptionsRpc);
|
|
269
|
+
const saveSettings = useRpc(rolesSaveSettingsRpc);
|
|
270
|
+
const queryClient = useQueryClient();
|
|
271
|
+
const [draft, setDraft] = useState<RoleDraft>(() => roleDraftOf(setting));
|
|
272
|
+
const [saving, setSaving] = useState(false);
|
|
273
|
+
const [error, setError] = useState<string | null>(null);
|
|
274
|
+
const options = useQuery({
|
|
275
|
+
queryKey: roleOptionsKey(draft.baseProvider),
|
|
276
|
+
queryFn: () => getOptions({ provider: draft.baseProvider }),
|
|
277
|
+
enabled: draft.baseProvider !== "",
|
|
278
|
+
});
|
|
279
|
+
const view = roleFormView({ role, setting, available, draft, options: options.data });
|
|
280
|
+
const input = saveSettingsInput(revision, role, view.draft);
|
|
281
|
+
const canSave = input !== null && view.blocker === null && view.changed && !saving;
|
|
282
|
+
|
|
283
|
+
const save = async () => {
|
|
284
|
+
if (input === null) return;
|
|
285
|
+
setSaving(true);
|
|
286
|
+
setError(null);
|
|
287
|
+
try {
|
|
288
|
+
const result = await saveSettings(input);
|
|
289
|
+
queryClient.setQueryData<RolesSettings>(ROLES_SETTINGS_KEY, (old) => (old === undefined ? old : applySavedRole(old, result)));
|
|
290
|
+
void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
|
|
291
|
+
setSaving(false);
|
|
292
|
+
onSaved(savedNotes(result));
|
|
293
|
+
} catch (failure) {
|
|
294
|
+
setError(saveErrorText(failure));
|
|
295
|
+
setSaving(false);
|
|
296
|
+
// Show the configuration as it is now; this form keeps its revision, so only reopening saves over it.
|
|
297
|
+
if (isSettingsConflict(failure)) void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 8 }]}>
|
|
303
|
+
<Text style={styles.sectionTitle}>{`Edit ${label}`}</Text>
|
|
304
|
+
{view.providers.length === 0 ? (
|
|
305
|
+
<Text style={styles.body}>Paseo reports no available provider right now.</Text>
|
|
306
|
+
) : (
|
|
307
|
+
<ChoiceField
|
|
308
|
+
title="Provider"
|
|
309
|
+
choices={view.providers.map((provider) => ({ id: provider, label: providerLabel(provider) }))}
|
|
310
|
+
selected={draft.baseProvider === "" ? null : draft.baseProvider}
|
|
311
|
+
onSelect={(id) => setDraft((current) => ({ ...current, baseProvider: id ?? "" }))}
|
|
312
|
+
styles={styles}
|
|
313
|
+
theme={theme}
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
{options.isLoading ? <ActivityIndicator color={styles.spinner.color} /> : null}
|
|
317
|
+
{options.isError ? (
|
|
318
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(options.error)}</Text>
|
|
319
|
+
) : null}
|
|
320
|
+
{view.models.length === 0 ? null : (
|
|
321
|
+
<ChoiceField
|
|
322
|
+
title="Model"
|
|
323
|
+
choices={view.models.map((model) => ({ id: model.id, label: model.label }))}
|
|
324
|
+
selected={view.draft.model}
|
|
325
|
+
onSelect={(id) => setDraft((current) => ({ ...current, model: id }))}
|
|
326
|
+
styles={styles}
|
|
327
|
+
theme={theme}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
{view.price === null ? null : <Text style={styles.body}>{view.price}</Text>}
|
|
331
|
+
{view.thinking.length === 0 ? null : (
|
|
332
|
+
<ChoiceField
|
|
333
|
+
title="Thinking"
|
|
334
|
+
choices={view.thinking}
|
|
335
|
+
selected={view.draft.thinkingOptionId}
|
|
336
|
+
onSelect={(id) => setDraft((current) => ({ ...current, thinkingOptionId: id }))}
|
|
337
|
+
styles={styles}
|
|
338
|
+
theme={theme}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
{view.modes.length === 0 ? null : (
|
|
342
|
+
<ChoiceField
|
|
343
|
+
title="Mode"
|
|
344
|
+
choices={view.modes}
|
|
345
|
+
selected={view.draft.modeId}
|
|
346
|
+
onSelect={(id) => setDraft((current) => ({ ...current, modeId: id }))}
|
|
347
|
+
styles={styles}
|
|
348
|
+
theme={theme}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
{view.modeNote === null ? null : <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{view.modeNote}</Text>}
|
|
352
|
+
{view.blocker === null || options.isError ? null : <Text style={styles.body}>{view.blocker}</Text>}
|
|
353
|
+
<View style={styles.chipRow}>
|
|
354
|
+
<Pressable
|
|
355
|
+
accessibilityRole="button"
|
|
356
|
+
disabled={!canSave}
|
|
357
|
+
onPress={() => void save()}
|
|
358
|
+
style={[styles.button, canSave ? null : { opacity: 0.5 }]}
|
|
359
|
+
>
|
|
360
|
+
<Text style={styles.buttonText}>{saving ? "Saving…" : "Save"}</Text>
|
|
361
|
+
</Pressable>
|
|
362
|
+
<Pressable accessibilityRole="button" disabled={saving} onPress={onClose} style={styles.secondaryButton}>
|
|
363
|
+
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
|
364
|
+
</Pressable>
|
|
365
|
+
</View>
|
|
366
|
+
{error === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{error}</Text>}
|
|
367
|
+
</View>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The Edit form of one fallback entry: the same fields and rules as a role's
|
|
373
|
+
* form (`roleFormView`), but it hands the entry back to its chain instead of
|
|
374
|
+
* saving; the chain is saved as a whole.
|
|
375
|
+
*/
|
|
376
|
+
function FallbackEntryForm({ role, entry, available, styles, theme, onDone, onCancel }: {
|
|
377
|
+
role: BmRole;
|
|
378
|
+
entry: FallbackEntryInput | null;
|
|
379
|
+
available: readonly string[];
|
|
380
|
+
styles: Styles;
|
|
381
|
+
theme: Theme;
|
|
382
|
+
onDone: (entry: FallbackEntryInput) => void;
|
|
383
|
+
onCancel: () => void;
|
|
384
|
+
}) {
|
|
385
|
+
const getOptions = useRpc(rolesOptionsRpc);
|
|
386
|
+
const setting = useMemo(() => entryAsSetting(role, entry), [role, entry]);
|
|
387
|
+
const [draft, setDraft] = useState<RoleDraft>(() => roleDraftOf(setting));
|
|
388
|
+
const options = useQuery({
|
|
389
|
+
queryKey: roleOptionsKey(draft.baseProvider),
|
|
390
|
+
queryFn: () => getOptions({ provider: draft.baseProvider }),
|
|
391
|
+
enabled: draft.baseProvider !== "",
|
|
392
|
+
});
|
|
393
|
+
const view = roleFormView({ role, setting, available, draft, options: options.data });
|
|
394
|
+
const done = view.blocker === null ? entryOfDraft(view.draft) : null;
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<View style={[styles.card, { backgroundColor: theme.colors.surface0, gap: 8 }]}>
|
|
398
|
+
<Text style={styles.sectionTitle}>{entry === null ? "Add fallback" : "Edit fallback"}</Text>
|
|
399
|
+
{view.providers.length === 0 ? (
|
|
400
|
+
<Text style={styles.body}>Paseo reports no available provider right now.</Text>
|
|
401
|
+
) : (
|
|
402
|
+
<ChoiceField
|
|
403
|
+
title="Provider"
|
|
404
|
+
choices={view.providers.map((provider) => ({ id: provider, label: providerLabel(provider) }))}
|
|
405
|
+
selected={draft.baseProvider === "" ? null : draft.baseProvider}
|
|
406
|
+
onSelect={(id) => setDraft((current) => ({ ...current, baseProvider: id ?? "" }))}
|
|
407
|
+
styles={styles}
|
|
408
|
+
theme={theme}
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
{options.isLoading ? <ActivityIndicator color={styles.spinner.color} /> : null}
|
|
412
|
+
{options.isError ? (
|
|
413
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(options.error)}</Text>
|
|
414
|
+
) : null}
|
|
415
|
+
{view.models.length === 0 ? null : (
|
|
416
|
+
<ChoiceField
|
|
417
|
+
title="Model"
|
|
418
|
+
choices={view.models.map((model) => ({ id: model.id, label: model.label }))}
|
|
419
|
+
selected={view.draft.model}
|
|
420
|
+
onSelect={(id) => setDraft((current) => ({ ...current, model: id }))}
|
|
421
|
+
styles={styles}
|
|
422
|
+
theme={theme}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
425
|
+
{view.price === null ? null : <Text style={styles.body}>{view.price}</Text>}
|
|
426
|
+
{view.thinking.length === 0 ? null : (
|
|
427
|
+
<ChoiceField
|
|
428
|
+
title="Thinking"
|
|
429
|
+
choices={view.thinking}
|
|
430
|
+
selected={view.draft.thinkingOptionId}
|
|
431
|
+
onSelect={(id) => setDraft((current) => ({ ...current, thinkingOptionId: id }))}
|
|
432
|
+
styles={styles}
|
|
433
|
+
theme={theme}
|
|
434
|
+
/>
|
|
435
|
+
)}
|
|
436
|
+
{view.modes.length === 0 ? null : (
|
|
437
|
+
<ChoiceField
|
|
438
|
+
title="Mode"
|
|
439
|
+
choices={view.modes}
|
|
440
|
+
selected={view.draft.modeId}
|
|
441
|
+
onSelect={(id) => setDraft((current) => ({ ...current, modeId: id }))}
|
|
442
|
+
styles={styles}
|
|
443
|
+
theme={theme}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
{view.modeNote === null ? null : <Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{view.modeNote}</Text>}
|
|
447
|
+
{view.blocker === null || options.isError ? null : <Text style={styles.body}>{view.blocker}</Text>}
|
|
448
|
+
<View style={styles.chipRow}>
|
|
449
|
+
<Pressable
|
|
450
|
+
accessibilityRole="button"
|
|
451
|
+
disabled={done === null}
|
|
452
|
+
onPress={() => done !== null && onDone(done)}
|
|
453
|
+
style={[styles.button, done === null ? { opacity: 0.5 } : null]}
|
|
454
|
+
>
|
|
455
|
+
<Text style={styles.buttonText}>{entry === null ? "Add" : "Done"}</Text>
|
|
456
|
+
</Pressable>
|
|
457
|
+
<Pressable accessibilityRole="button" onPress={onCancel} style={styles.secondaryButton}>
|
|
458
|
+
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
|
459
|
+
</Pressable>
|
|
460
|
+
</View>
|
|
461
|
+
</View>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A small text button of an entry row: ↑, ↓, Edit, Remove. */
|
|
466
|
+
function RowButton({ label, accessibilityLabel, disabled, onPress, styles }: {
|
|
467
|
+
label: string;
|
|
468
|
+
accessibilityLabel: string;
|
|
469
|
+
disabled?: boolean;
|
|
470
|
+
onPress: () => void;
|
|
471
|
+
styles: Styles;
|
|
472
|
+
}) {
|
|
473
|
+
return (
|
|
474
|
+
<Pressable
|
|
475
|
+
accessibilityRole="button"
|
|
476
|
+
accessibilityLabel={accessibilityLabel}
|
|
477
|
+
disabled={disabled}
|
|
478
|
+
onPress={onPress}
|
|
479
|
+
style={[styles.secondaryButton, disabled ? { opacity: 0.4 } : null]}
|
|
480
|
+
>
|
|
481
|
+
<Text style={styles.secondaryButtonText}>{label}</Text>
|
|
482
|
+
</Pressable>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The fallback chain of one role (§4.3.1): the policy, the entries with
|
|
488
|
+
* reorder, edit and remove, and "Add fallback". Edits stay local until Save;
|
|
489
|
+
* the block keeps the `revision` it had when the user started editing, so a
|
|
490
|
+
* save over a configuration changed since then is refused.
|
|
491
|
+
*/
|
|
492
|
+
function FallbackBlock({ chain, label, revision, available, optionsOf, styles, theme }: {
|
|
493
|
+
chain: FallbackSettings;
|
|
494
|
+
label: string;
|
|
495
|
+
revision: string;
|
|
496
|
+
available: readonly string[];
|
|
497
|
+
optionsOf: (provider: string) => RolesOptions | undefined;
|
|
498
|
+
styles: Styles;
|
|
499
|
+
theme: Theme;
|
|
500
|
+
}) {
|
|
501
|
+
const saveFallback = useRpc(rolesSaveFallbackRpc);
|
|
502
|
+
const queryClient = useQueryClient();
|
|
503
|
+
const [editing, setEditing] = useState<{ draft: FallbackDraft; revision: string } | null>(null);
|
|
504
|
+
const [form, setForm] = useState<{ index: number | null } | null>(null);
|
|
505
|
+
const [saving, setSaving] = useState(false);
|
|
506
|
+
const [notes, setNotes] = useState<Badge[]>([]);
|
|
507
|
+
const [error, setError] = useState<string | null>(null);
|
|
508
|
+
const draft = editing?.draft ?? fallbackDraftOf(chain);
|
|
509
|
+
const changed = editing !== null && fallbackDraftChanged(chain, editing.draft);
|
|
510
|
+
|
|
511
|
+
const edit = (next: (current: FallbackDraft) => FallbackDraft) => {
|
|
512
|
+
setNotes([]);
|
|
513
|
+
setError(null);
|
|
514
|
+
setEditing((current) => ({ draft: next(current?.draft ?? fallbackDraftOf(chain)), revision: current?.revision ?? revision }));
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const save = async () => {
|
|
518
|
+
if (editing === null) return;
|
|
519
|
+
setSaving(true);
|
|
520
|
+
setError(null);
|
|
521
|
+
try {
|
|
522
|
+
const result = await saveFallback(saveFallbackInput(editing.revision, chain.role, editing.draft));
|
|
523
|
+
setEditing(null);
|
|
524
|
+
setNotes(savedNotes(result));
|
|
525
|
+
void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
|
|
526
|
+
} catch (failure) {
|
|
527
|
+
setError(saveErrorText(failure));
|
|
528
|
+
if (isSettingsConflict(failure)) void queryClient.invalidateQueries({ queryKey: ROLES_SETTINGS_KEY });
|
|
529
|
+
} finally {
|
|
530
|
+
setSaving(false);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<View style={{ gap: 6, paddingLeft: 12 }}>
|
|
536
|
+
<View style={[styles.chipRow, { alignItems: "center" }]}>
|
|
537
|
+
<Text style={styles.body}>On a usage limit:</Text>
|
|
538
|
+
{FALLBACK_POLICY_CHOICES.map((choice) => {
|
|
539
|
+
const on = draft.policy === choice.id;
|
|
540
|
+
return (
|
|
541
|
+
<Chip
|
|
542
|
+
key={choice.id}
|
|
543
|
+
badge={{ text: choice.label, tone: on ? "info" : "muted" }}
|
|
544
|
+
selected={on}
|
|
545
|
+
onPress={() => edit((current) => ({ ...current, policy: choice.id }))}
|
|
546
|
+
styles={styles}
|
|
547
|
+
theme={theme}
|
|
548
|
+
/>
|
|
549
|
+
);
|
|
550
|
+
})}
|
|
551
|
+
</View>
|
|
552
|
+
{fallbackPolicyWarning(chain.role, draft.policy) === null ? null : (
|
|
553
|
+
<Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{fallbackPolicyWarning(chain.role, draft.policy)}</Text>
|
|
554
|
+
)}
|
|
555
|
+
{draft.entries.map((entry, index) => {
|
|
556
|
+
const saved = editing === null ? chain.entries[index] : undefined;
|
|
557
|
+
const price = saved === undefined ? null : fallbackPriceText(saved);
|
|
558
|
+
return (
|
|
559
|
+
<View key={`${index}:${entry.baseProvider}:${entry.model}`} style={{ gap: 4 }}>
|
|
560
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
|
561
|
+
<Text style={styles.body}>{`Fallback ${index + 1}`}</Text>
|
|
562
|
+
<Text style={[styles.body, { flex: 1, minWidth: 160 }]} selectable>
|
|
563
|
+
{fallbackEntryText(entry, optionsOf(entry.baseProvider))}
|
|
564
|
+
</Text>
|
|
565
|
+
<RowButton label="↑" accessibilityLabel={`Move ${label} fallback ${index + 1} up`} disabled={index === 0} onPress={() => edit((current) => moveFallback(current, index, -1))} styles={styles} />
|
|
566
|
+
<RowButton
|
|
567
|
+
label="↓"
|
|
568
|
+
accessibilityLabel={`Move ${label} fallback ${index + 1} down`}
|
|
569
|
+
disabled={index === draft.entries.length - 1}
|
|
570
|
+
onPress={() => edit((current) => moveFallback(current, index, 1))}
|
|
571
|
+
styles={styles}
|
|
572
|
+
/>
|
|
573
|
+
<RowButton label="Edit" accessibilityLabel={`Edit ${label} fallback ${index + 1}`} onPress={() => setForm({ index })} styles={styles} />
|
|
574
|
+
<RowButton label="Remove" accessibilityLabel={`Remove ${label} fallback ${index + 1}`} onPress={() => edit((current) => removeFallback(current, index))} styles={styles} />
|
|
575
|
+
</View>
|
|
576
|
+
{price === null ? null : <Text style={styles.body}>{price}</Text>}
|
|
577
|
+
{form?.index === index ? (
|
|
578
|
+
<FallbackEntryForm
|
|
579
|
+
role={chain.role}
|
|
580
|
+
entry={entry}
|
|
581
|
+
available={available}
|
|
582
|
+
styles={styles}
|
|
583
|
+
theme={theme}
|
|
584
|
+
onDone={(next) => {
|
|
585
|
+
edit((current) => replaceFallback(current, index, next));
|
|
586
|
+
setForm(null);
|
|
587
|
+
}}
|
|
588
|
+
onCancel={() => setForm(null)}
|
|
589
|
+
/>
|
|
590
|
+
) : null}
|
|
591
|
+
</View>
|
|
592
|
+
);
|
|
593
|
+
})}
|
|
594
|
+
{form !== null && form.index === null ? (
|
|
595
|
+
<FallbackEntryForm
|
|
596
|
+
role={chain.role}
|
|
597
|
+
entry={null}
|
|
598
|
+
available={available}
|
|
599
|
+
styles={styles}
|
|
600
|
+
theme={theme}
|
|
601
|
+
onDone={(next) => {
|
|
602
|
+
edit((current) => addFallback(current, next));
|
|
603
|
+
setForm(null);
|
|
604
|
+
}}
|
|
605
|
+
onCancel={() => setForm(null)}
|
|
606
|
+
/>
|
|
607
|
+
) : canAddFallback(draft) ? (
|
|
608
|
+
<View style={styles.chipRow}>
|
|
609
|
+
<RowButton label="+ Add fallback" accessibilityLabel={`Add a fallback for the ${label}`} onPress={() => setForm({ index: null })} styles={styles} />
|
|
610
|
+
</View>
|
|
611
|
+
) : null}
|
|
612
|
+
{editing === null ? null : (
|
|
613
|
+
<View style={styles.chipRow}>
|
|
614
|
+
<Pressable
|
|
615
|
+
accessibilityRole="button"
|
|
616
|
+
disabled={!changed || saving}
|
|
617
|
+
onPress={() => void save()}
|
|
618
|
+
style={[styles.button, changed && !saving ? null : { opacity: 0.5 }]}
|
|
619
|
+
>
|
|
620
|
+
<Text style={styles.buttonText}>{saving ? "Saving…" : "Save fallbacks"}</Text>
|
|
621
|
+
</Pressable>
|
|
622
|
+
<Pressable
|
|
623
|
+
accessibilityRole="button"
|
|
624
|
+
disabled={saving}
|
|
625
|
+
onPress={() => {
|
|
626
|
+
setEditing(null);
|
|
627
|
+
setForm(null);
|
|
628
|
+
setError(null);
|
|
629
|
+
}}
|
|
630
|
+
style={styles.secondaryButton}
|
|
631
|
+
>
|
|
632
|
+
<Text style={styles.secondaryButtonText}>Discard</Text>
|
|
633
|
+
</Pressable>
|
|
634
|
+
</View>
|
|
635
|
+
)}
|
|
636
|
+
{notes.map((line, index) => (
|
|
637
|
+
<Text key={`${index}:${line.text}`} style={[styles.body, { color: toneColor(theme, line.tone) }]}>
|
|
638
|
+
{line.text}
|
|
639
|
+
</Text>
|
|
640
|
+
))}
|
|
641
|
+
{error === null ? null : <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{error}</Text>}
|
|
642
|
+
</View>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** "Roles & models": one row per role in the order `roles.settings` returns, each with an Edit form. */
|
|
647
|
+
function RolesSection({ styles, theme }: { styles: Styles; theme: Theme }) {
|
|
648
|
+
const getSettings = useRpc(rolesSettingsRpc);
|
|
649
|
+
const getOptions = useRpc(rolesOptionsRpc);
|
|
650
|
+
const settings = useQuery({ queryKey: ROLES_SETTINGS_KEY, queryFn: () => getSettings({}) });
|
|
651
|
+
const data = settings.data;
|
|
652
|
+
const providers = data === undefined ? [] : rowOptionProviders(data);
|
|
653
|
+
const optionQueries = useQueries({
|
|
654
|
+
queries: providers.map((provider) => ({ queryKey: roleOptionsKey(provider), queryFn: () => getOptions({ provider }) })),
|
|
655
|
+
});
|
|
656
|
+
const optionsOf = (provider: string) => optionQueries[providers.indexOf(provider)]?.data;
|
|
657
|
+
const rows = data === undefined ? [] : roleRows(data, optionsOf);
|
|
658
|
+
const chains = new Map((data === undefined ? [] : fallbackBlocks(data)).map((chain) => [chain.role, chain] as const));
|
|
659
|
+
const [editing, setEditing] = useState<{ role: BmRole; revision: string } | null>(null);
|
|
660
|
+
const [notes, setNotes] = useState<{ role: BmRole; lines: Badge[] } | null>(null);
|
|
661
|
+
|
|
662
|
+
return (
|
|
663
|
+
<>
|
|
664
|
+
<Text style={styles.sectionTitle}>Roles & models</Text>
|
|
665
|
+
{settings.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
|
|
666
|
+
{settings.isError ? (
|
|
667
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(settings.error)}</Text>
|
|
668
|
+
) : null}
|
|
669
|
+
{data === undefined ? null : (
|
|
670
|
+
<View style={[styles.card, { gap: 10 }]}>
|
|
671
|
+
{rows.map((row) => {
|
|
672
|
+
const open = editing?.role === row.role;
|
|
673
|
+
return (
|
|
674
|
+
<View key={row.role} style={{ gap: 6 }}>
|
|
675
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
676
|
+
<RoleMark kind={row.mark} theme={theme} />
|
|
677
|
+
<Text style={styles.sectionTitle}>{row.label}</Text>
|
|
678
|
+
<Text style={[styles.body, { flex: 1, minWidth: 160 }]} selectable>
|
|
679
|
+
{row.text}
|
|
680
|
+
</Text>
|
|
681
|
+
<Pressable
|
|
682
|
+
accessibilityRole="button"
|
|
683
|
+
accessibilityLabel={open ? `Close the ${row.label} form` : `Edit the ${row.label} provider, model, thinking and mode`}
|
|
684
|
+
accessibilityState={{ expanded: open }}
|
|
685
|
+
onPress={() => {
|
|
686
|
+
setNotes(null);
|
|
687
|
+
setEditing(open ? null : { role: row.role, revision: data.revision });
|
|
688
|
+
}}
|
|
689
|
+
style={styles.secondaryButton}
|
|
690
|
+
>
|
|
691
|
+
<Text style={styles.secondaryButtonText}>{open ? "Close" : "Edit"}</Text>
|
|
692
|
+
</Pressable>
|
|
693
|
+
</View>
|
|
694
|
+
{notes?.role === row.role
|
|
695
|
+
? notes.lines.map((line, index) => (
|
|
696
|
+
<Text key={`${index}:${line.text}`} style={[styles.body, { color: toneColor(theme, line.tone) }]}>
|
|
697
|
+
{line.text}
|
|
698
|
+
</Text>
|
|
699
|
+
))
|
|
700
|
+
: null}
|
|
701
|
+
{chains.get(row.role) === undefined ? null : (
|
|
702
|
+
<FallbackBlock
|
|
703
|
+
chain={chains.get(row.role)!}
|
|
704
|
+
label={row.label}
|
|
705
|
+
revision={data.revision}
|
|
706
|
+
available={data.providers}
|
|
707
|
+
optionsOf={optionsOf}
|
|
708
|
+
styles={styles}
|
|
709
|
+
theme={theme}
|
|
710
|
+
/>
|
|
711
|
+
)}
|
|
712
|
+
{open ? (
|
|
713
|
+
<RoleEditForm
|
|
714
|
+
role={row.role}
|
|
715
|
+
label={row.label}
|
|
716
|
+
setting={row.setting}
|
|
717
|
+
available={data.providers}
|
|
718
|
+
revision={editing.revision}
|
|
719
|
+
styles={styles}
|
|
720
|
+
theme={theme}
|
|
721
|
+
onClose={() => setEditing(null)}
|
|
722
|
+
onSaved={(lines) => {
|
|
723
|
+
setEditing(null);
|
|
724
|
+
setNotes({ role: row.role, lines });
|
|
725
|
+
}}
|
|
726
|
+
/>
|
|
727
|
+
) : null}
|
|
728
|
+
</View>
|
|
729
|
+
);
|
|
730
|
+
})}
|
|
731
|
+
</View>
|
|
732
|
+
)}
|
|
733
|
+
{data?.warnings.map((warning) => (
|
|
734
|
+
<Text key={warning} style={[styles.body, { color: toneColor(theme, "warning") }]}>
|
|
735
|
+
{warning}
|
|
736
|
+
</Text>
|
|
737
|
+
))}
|
|
738
|
+
<Text style={styles.body}>{ROLES_APPLY_NOTICE}</Text>
|
|
739
|
+
</>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function ToolCard({ tool, styles, theme, onInstalled }: {
|
|
744
|
+
tool: SetupStatus["tools"][number];
|
|
745
|
+
styles: Styles;
|
|
746
|
+
theme: Theme;
|
|
747
|
+
onInstalled: () => void;
|
|
748
|
+
}) {
|
|
749
|
+
const install = useRpc(setupInstallToolRpc);
|
|
750
|
+
const [confirming, setConfirming] = useState(false);
|
|
751
|
+
const [busy, setBusy] = useState(false);
|
|
752
|
+
const [result, setResult] = useState<{ text: string; tone: "success" | "danger"; tail: string[] } | null>(null);
|
|
753
|
+
const badge = toolBadge(tool);
|
|
754
|
+
const installable = tool.path === null && tool.installCommand !== null && (tool.id === "br" || tool.id === "bv");
|
|
755
|
+
|
|
756
|
+
const run = async () => {
|
|
757
|
+
if (tool.id === "bd") return;
|
|
758
|
+
setBusy(true);
|
|
759
|
+
setResult(null);
|
|
760
|
+
try {
|
|
761
|
+
const done = await install({ tool: tool.id, confirmed: true });
|
|
762
|
+
setResult({ text: `Installed with \`${done.command}\`.`, tone: "success", tail: done.tail });
|
|
763
|
+
onInstalled();
|
|
764
|
+
} catch (failure) {
|
|
765
|
+
setResult({ text: errorMessageOf(failure), tone: "danger", tail: [] });
|
|
766
|
+
} finally {
|
|
767
|
+
setBusy(false);
|
|
768
|
+
setConfirming(false);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
<View style={[styles.card, { gap: 6 }]}>
|
|
774
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
775
|
+
<Text style={[styles.sectionTitle, { flex: 1 }]}>{tool.name}</Text>
|
|
776
|
+
<Chip badge={badge} styles={styles} theme={theme} />
|
|
777
|
+
</View>
|
|
778
|
+
<Text style={styles.body}>{tool.purpose}</Text>
|
|
779
|
+
<Text style={styles.body} selectable>
|
|
780
|
+
{tool.path === null ? "Not found on the daemon's PATH or the usual install folders." : `Found at ${tool.path}`}
|
|
781
|
+
</Text>
|
|
782
|
+
{installable ? (
|
|
783
|
+
confirming ? (
|
|
784
|
+
<View style={{ gap: 6 }}>
|
|
785
|
+
<Text style={[styles.body, { color: toneColor(theme, "warning") }]}>{installWarning(tool)}</Text>
|
|
786
|
+
<View style={styles.chipRow}>
|
|
787
|
+
<Pressable accessibilityRole="button" disabled={busy} onPress={() => void run()} style={styles.button}>
|
|
788
|
+
<Text style={styles.buttonText}>{busy ? "Installing… (up to 5 minutes)" : `Install ${tool.id}`}</Text>
|
|
789
|
+
</Pressable>
|
|
790
|
+
{busy ? null : (
|
|
791
|
+
<Pressable accessibilityRole="button" onPress={() => setConfirming(false)} style={styles.secondaryButton}>
|
|
792
|
+
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
|
793
|
+
</Pressable>
|
|
794
|
+
)}
|
|
795
|
+
</View>
|
|
796
|
+
</View>
|
|
797
|
+
) : (
|
|
798
|
+
<View style={{ gap: 6 }}>
|
|
799
|
+
<CommandLine label="Install command" command={tool.installCommand!} styles={styles} theme={theme} />
|
|
800
|
+
<Pressable accessibilityRole="button" onPress={() => setConfirming(true)} style={[styles.button, { alignSelf: "flex-start" }]}>
|
|
801
|
+
<Text style={styles.buttonText}>{`Install ${tool.id}…`}</Text>
|
|
802
|
+
</Pressable>
|
|
803
|
+
</View>
|
|
804
|
+
)
|
|
805
|
+
) : tool.updateCommand !== null ? (
|
|
806
|
+
<CommandLine label="Update it yourself with" command={tool.updateCommand} styles={styles} theme={theme} />
|
|
807
|
+
) : null}
|
|
808
|
+
{result === null ? null : (
|
|
809
|
+
<View style={{ gap: 2 }}>
|
|
810
|
+
<Text style={[styles.body, { color: toneColor(theme, result.tone) }]}>{result.text}</Text>
|
|
811
|
+
{result.tail.length === 0 ? null : (
|
|
812
|
+
<Text style={[styles.mono, { backgroundColor: theme.colors.surface0, padding: 6, borderRadius: 6 }]} selectable>
|
|
813
|
+
{result.tail.join("\n")}
|
|
814
|
+
</Text>
|
|
815
|
+
)}
|
|
816
|
+
</View>
|
|
817
|
+
)}
|
|
818
|
+
<Text style={[styles.body, { fontSize: 11 }]} selectable>
|
|
819
|
+
{tool.homepage}
|
|
820
|
+
</Text>
|
|
821
|
+
</View>
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export function SetupScreen({ theme, layout, onOpenWorkspaces, status: statusStrip }: SetupScreenProps) {
|
|
826
|
+
const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
|
|
827
|
+
const getStatus = useRpc(setupStatusRpc);
|
|
828
|
+
const status = useQuery({ queryKey: ["paseo-bm", "setup", "status"], queryFn: () => getStatus({}) });
|
|
829
|
+
const data = status.data;
|
|
830
|
+
const headline = data === undefined ? null : setupHeadline(data);
|
|
831
|
+
|
|
832
|
+
return (
|
|
833
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
834
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
835
|
+
<Text style={[styles.title, { flex: 1 }]} numberOfLines={1}>
|
|
836
|
+
Beads Manager
|
|
837
|
+
</Text>
|
|
838
|
+
<Pressable
|
|
839
|
+
accessibilityRole="button"
|
|
840
|
+
accessibilityLabel="Open the workspace list: Beads Manager, metrics and beads of each workspace"
|
|
841
|
+
onPress={onOpenWorkspaces}
|
|
842
|
+
style={styles.secondaryButton}
|
|
843
|
+
>
|
|
844
|
+
<Text style={styles.secondaryButtonText}>Workspaces</Text>
|
|
845
|
+
</Pressable>
|
|
846
|
+
</View>
|
|
847
|
+
<Text style={styles.body}>Setup for this machine: beads tools, agent skills, and each role's model and extra instructions.</Text>
|
|
848
|
+
{statusStrip}
|
|
849
|
+
{status.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
|
|
850
|
+
{status.isError ? (
|
|
851
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(status.error)}</Text>
|
|
852
|
+
) : null}
|
|
853
|
+
{headline === null ? null : <Text style={[styles.sectionTitle, { color: toneColor(theme, headline.tone) }]}>{headline.text}</Text>}
|
|
854
|
+
{data === undefined
|
|
855
|
+
? null
|
|
856
|
+
: paseoToolsWarnings(data).map((warning) => (
|
|
857
|
+
<Text key={warning} style={[styles.body, { color: toneColor(theme, "warning") }]}>
|
|
858
|
+
{warning}
|
|
859
|
+
</Text>
|
|
860
|
+
))}
|
|
861
|
+
|
|
862
|
+
{/* 1. Beads tools */}
|
|
863
|
+
<Text style={styles.sectionTitle}>Beads tools</Text>
|
|
864
|
+
{data?.tools.map((tool) => (
|
|
865
|
+
<ToolCard key={tool.id} tool={tool} styles={styles} theme={theme} onInstalled={() => void status.refetch()} />
|
|
866
|
+
))}
|
|
867
|
+
{data === undefined ? null : (
|
|
868
|
+
<Text style={[styles.body, { fontSize: 11 }]}>{`Newest versions as of ${data.latestCheckedOn}; paseo-bm does not look them up online.`}</Text>
|
|
869
|
+
)}
|
|
870
|
+
|
|
871
|
+
{/* 2. Agent skills */}
|
|
872
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
873
|
+
<Text style={[styles.sectionTitle, { flex: 1 }]}>Agent skills</Text>
|
|
874
|
+
<Pressable accessibilityRole="button" disabled={status.isFetching} onPress={() => void status.refetch()} style={styles.secondaryButton}>
|
|
875
|
+
<Text style={styles.secondaryButtonText}>{status.isFetching ? "Testing…" : "Test"}</Text>
|
|
876
|
+
</Pressable>
|
|
877
|
+
</View>
|
|
878
|
+
{data === undefined ? null : (
|
|
879
|
+
<View style={[styles.card, { gap: 6 }]}>
|
|
880
|
+
<Text style={styles.body}>{`Checked ${new Date(data.skills.checkedAt).toLocaleTimeString()} · ${skillDirsText(data.skills.dirs)}`}</Text>
|
|
881
|
+
{data.skills.skills.map((skill) => (
|
|
882
|
+
<View key={skill.name} style={{ gap: 2 }}>
|
|
883
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
|
884
|
+
<Text style={[styles.mono, { flex: 1 }]}>{`${skill.name}${skill.required ? "" : " (optional)"}`}</Text>
|
|
885
|
+
{skillChips(skill).map((badge) => (
|
|
886
|
+
<Chip key={badge.text} badge={badge} styles={styles} theme={theme} />
|
|
887
|
+
))}
|
|
888
|
+
</View>
|
|
889
|
+
{skill.problem === null ? null : (
|
|
890
|
+
<Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{skill.problem}</Text>
|
|
891
|
+
)}
|
|
892
|
+
</View>
|
|
893
|
+
))}
|
|
894
|
+
<CommandLine label="Install the required skills for Claude Code and Codex (run it yourself, or use `npx paseo-bm install --apply --install-skills`)" command={data.skills.installCommand} styles={styles} theme={theme} />
|
|
895
|
+
</View>
|
|
896
|
+
)}
|
|
897
|
+
|
|
898
|
+
{/* 3. Roles & models (delta 20260921 §4.3.1) */}
|
|
899
|
+
<RolesSection styles={styles} theme={theme} />
|
|
900
|
+
|
|
901
|
+
{/* 4. Additional instructions per role */}
|
|
902
|
+
<Text style={styles.sectionTitle}>Additional instructions</Text>
|
|
903
|
+
<Text style={styles.body}>
|
|
904
|
+
Added after each role's built-in instructions, for agents created from now on. Running agents keep what they started with.
|
|
905
|
+
</Text>
|
|
906
|
+
{SETUP_ROLES.map((entry) => (
|
|
907
|
+
<RoleCard key={entry.role} {...entry} styles={styles} theme={theme} compact={layout.compact} />
|
|
908
|
+
))}
|
|
909
|
+
|
|
910
|
+
<Text style={[styles.body, { fontSize: 11 }]}>{`paseo-bm ${PLUGIN_VERSION}`}</Text>
|
|
911
|
+
</ScrollView>
|
|
912
|
+
);
|
|
913
|
+
}
|