paseo-plugin-zcode-provider 0.1.0 → 0.3.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.
@@ -0,0 +1,827 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Linking, Platform, Text, View } from "react-native";
3
+ import {
4
+ getPaseoClient,
5
+ openExternalUrl,
6
+ type PluginSurfaceProps,
7
+ useRpc,
8
+ } from "@getpaseo/plugin/client";
9
+ import { copyText } from "@getpaseo/plugin/client/react-native";
10
+ import {
11
+ SettingsAction,
12
+ SettingsCard,
13
+ SettingsInput,
14
+ SettingsRow,
15
+ SettingsSection,
16
+ SettingsSelect,
17
+ SettingsSwitch,
18
+ } from "@getpaseo/plugin/client/ui";
19
+ import {
20
+ zcodeAccountChange,
21
+ zcodeAccountView,
22
+ type AccountChange,
23
+ type AccountFamily,
24
+ type AccountView,
25
+ type ApiFormat,
26
+ type CustomProvider,
27
+ type ProviderTemplate,
28
+ type ReadyAccountView,
29
+ isHttpUrl,
30
+ } from "../shared/account";
31
+ import { Value } from "./value";
32
+
33
+ type Theme = PluginSurfaceProps["theme"];
34
+ type Run = (change: AccountChange) => Promise<AccountView | null>;
35
+ interface PartProps {
36
+ readonly theme: Theme;
37
+ readonly compact: boolean;
38
+ readonly busy: boolean;
39
+ readonly run: Run;
40
+ }
41
+
42
+ const FAMILIES: readonly { label: string; value: AccountFamily }[] = [
43
+ { label: "Z.ai (international)", value: "zai" },
44
+ { label: "BigModel (China)", value: "bigmodel" },
45
+ ];
46
+ const API_FORMATS: readonly { label: string; value: ApiFormat }[] = [
47
+ { label: "Anthropic messages (/messages)", value: "anthropic-messages" },
48
+ {
49
+ label: "Chat completions (/chat/completions)",
50
+ value: "openai-chat-completions",
51
+ },
52
+ { label: "Responses (/responses)", value: "openai-responses" },
53
+ ];
54
+ const CUSTOM = "custom";
55
+
56
+ const failure = (cause: unknown) =>
57
+ cause instanceof Error ? cause.message : "The account request failed.";
58
+
59
+ // openExternalUrl, copyText and getPaseoClient arrived in Paseo 0.9; 0.8
60
+ // hosts omit them.
61
+ async function openUrl(url: string): Promise<void> {
62
+ if (typeof openExternalUrl === "function") await openExternalUrl(url);
63
+ else await Linking.openURL(url);
64
+ }
65
+ const canCopy = typeof copyText === "function";
66
+
67
+ // Paseo caches provider catalogs, so new models would otherwise appear only
68
+ // after a manual refresh.
69
+ function refreshCatalog(serverId: string): void {
70
+ if (typeof getPaseoClient !== "function") return;
71
+ getPaseoClient(serverId)
72
+ .providers.refresh({ providers: ["zcode"] })
73
+ .catch(() => {});
74
+ }
75
+
76
+ function planStatus(plan: ReadyAccountView["plans"][number]): string {
77
+ if (plan.availability === "available") return "Available";
78
+ if (plan.availability === "pending") return "Checking…";
79
+ switch (plan.unavailableReason) {
80
+ case "not-entitled":
81
+ return "No subscription";
82
+ case "not-connected":
83
+ return "Not selected";
84
+ case "not-authenticated":
85
+ return "Sign-in required";
86
+ case "credential-failed":
87
+ return "Could not verify the account";
88
+ }
89
+ return plan.availability === "unknown" ? "Unknown" : "Unavailable";
90
+ }
91
+
92
+ function providerStatus(provider: CustomProvider): string {
93
+ if (!provider.enabled) return "Disabled";
94
+ if (provider.issues.length > 0) return provider.issues.join(", ");
95
+ if (!provider.executable)
96
+ return provider.models.some((model) => model.enabled)
97
+ ? "Not ready"
98
+ : "Add a model to use it";
99
+ return "Ready";
100
+ }
101
+
102
+ function modelHint(model: CustomProvider["models"][number]): string {
103
+ const parts: string[] = [];
104
+ if (model.contextWindow !== undefined)
105
+ parts.push(
106
+ model.contextWindow >= 1_000_000
107
+ ? `${model.contextWindow / 1_000_000}M context`
108
+ : `${Math.round(model.contextWindow / 1000)}K context`,
109
+ );
110
+ if (model.vision) parts.push("Vision");
111
+ parts.push(model.builtin ? "From template" : "Added by you");
112
+ return parts.join(" · ");
113
+ }
114
+
115
+ function Monospace({
116
+ children,
117
+ theme,
118
+ compact,
119
+ }: {
120
+ children: string;
121
+ theme: Theme;
122
+ compact: boolean;
123
+ }) {
124
+ // A full-width block: a row value would squeeze the label column.
125
+ return (
126
+ <View style={{ padding: compact ? 12 : 16 }}>
127
+ <Text
128
+ selectable
129
+ style={{
130
+ color: theme.colors.foreground,
131
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
132
+ fontSize: compact ? 12 : 13,
133
+ lineHeight: 20,
134
+ }}
135
+ >
136
+ {children}
137
+ </Text>
138
+ </View>
139
+ );
140
+ }
141
+
142
+ function Account({
143
+ view,
144
+ theme,
145
+ compact,
146
+ busy,
147
+ run,
148
+ }: PartProps & { view: ReadyAccountView }) {
149
+ const [family, setFamily] = useState<AccountFamily>("zai");
150
+ const [confirmSignOut, setConfirmSignOut] = useState(false);
151
+ const [openError, setOpenError] = useState<string | null>(null);
152
+ const { account, signIn } = view;
153
+ const open = (url: string) => {
154
+ setOpenError(null);
155
+ openUrl(url).catch(() =>
156
+ setOpenError("The browser could not be opened. Copy the link instead."),
157
+ );
158
+ };
159
+ const start = async () => {
160
+ const next = await run({ action: "signIn", family });
161
+ if (next?.status === "ready" && next.signIn.state === "pending")
162
+ open(next.signIn.authorizeUrl);
163
+ };
164
+ const region = FAMILIES.find(
165
+ (entry) =>
166
+ entry.value ===
167
+ (account.state === "signed-out" ? undefined : account.family),
168
+ )?.label;
169
+
170
+ return (
171
+ <SettingsSection title="ZCode account">
172
+ <SettingsCard>
173
+ <SettingsRow
174
+ label="Status"
175
+ hint="Shared with ZCode Desktop and the CLI on the daemon machine."
176
+ >
177
+ <Value
178
+ color={
179
+ account.state === "signed-in"
180
+ ? theme.colors.statusSuccess
181
+ : account.state === "reauthentication-required"
182
+ ? theme.colors.statusWarning
183
+ : theme.colors.foregroundMuted
184
+ }
185
+ compact={compact}
186
+ >
187
+ {account.state === "signed-in"
188
+ ? `Signed in${account.displayName ? ` as ${account.displayName}` : ""}`
189
+ : account.state === "reauthentication-required"
190
+ ? "Session expired. Sign in again."
191
+ : "Signed out"}
192
+ </Value>
193
+ </SettingsRow>
194
+ {region === undefined ? null : (
195
+ <SettingsRow label="Region">
196
+ <Value color={theme.colors.foregroundMuted} compact={compact}>
197
+ {region}
198
+ </Value>
199
+ </SettingsRow>
200
+ )}
201
+ {/* SettingsCard divides direct children, so rows are keyed arrays. */}
202
+ {signIn.state === "pending" ? (
203
+ [
204
+ <SettingsRow
205
+ key="waiting"
206
+ label="Waiting for sign-in"
207
+ hint="Finish in the browser on any device. The daemon receives the result; the link expires after five minutes."
208
+ error={openError}
209
+ />,
210
+ <Monospace key="url" theme={theme} compact={compact}>
211
+ {signIn.authorizeUrl}
212
+ </Monospace>,
213
+ <SettingsAction
214
+ key="open"
215
+ label="Sign-in page"
216
+ actionLabel="Open"
217
+ onPress={() => open(signIn.authorizeUrl)}
218
+ />,
219
+ canCopy ? (
220
+ <SettingsAction
221
+ key="copy"
222
+ label="Sign-in link"
223
+ actionLabel="Copy"
224
+ onPress={() => void copyText(signIn.authorizeUrl)}
225
+ />
226
+ ) : null,
227
+ <SettingsAction
228
+ key="cancel"
229
+ label="Cancel sign-in"
230
+ actionLabel="Cancel"
231
+ disabled={busy}
232
+ onPress={() => void run({ action: "cancelSignIn" })}
233
+ />,
234
+ ]
235
+ ) : account.state === "signed-in" ? (
236
+ <SettingsAction
237
+ label="Sign out"
238
+ hint="New sessions can no longer use the account's plans."
239
+ actionLabel={confirmSignOut ? "Confirm sign out" : "Sign out"}
240
+ disabled={busy}
241
+ onPress={() => {
242
+ if (!confirmSignOut) {
243
+ setConfirmSignOut(true);
244
+ return;
245
+ }
246
+ setConfirmSignOut(false);
247
+ void run({ action: "signOut" });
248
+ }}
249
+ />
250
+ ) : (
251
+ [
252
+ <SettingsSelect<AccountFamily>
253
+ key="region"
254
+ label="Region"
255
+ value={family}
256
+ options={FAMILIES}
257
+ onValueChange={setFamily}
258
+ disabled={busy}
259
+ />,
260
+ <SettingsAction
261
+ key="sign-in"
262
+ label="Sign in"
263
+ hint="Opens the sign-in page in your browser."
264
+ error={signIn.state === "failed" ? signIn.message : null}
265
+ actionLabel="Sign in"
266
+ disabled={busy}
267
+ onPress={() => void start()}
268
+ />,
269
+ ]
270
+ )}
271
+ </SettingsCard>
272
+ </SettingsSection>
273
+ );
274
+ }
275
+
276
+ function Plans({
277
+ view,
278
+ theme,
279
+ compact,
280
+ }: {
281
+ view: ReadyAccountView;
282
+ theme: Theme;
283
+ compact: boolean;
284
+ }) {
285
+ if (view.account.state === "signed-out" || view.plans.length === 0)
286
+ return null;
287
+ return (
288
+ <SettingsSection title="Plans">
289
+ <SettingsCard>
290
+ {view.plans.map((plan) => (
291
+ <SettingsRow
292
+ key={plan.providerId}
293
+ label={plan.name}
294
+ hint={plan.models.length > 0 ? plan.models.join(", ") : undefined}
295
+ >
296
+ <Value
297
+ color={
298
+ plan.availability === "available"
299
+ ? theme.colors.statusSuccess
300
+ : theme.colors.foregroundMuted
301
+ }
302
+ compact={compact}
303
+ >
304
+ {planStatus(plan)}
305
+ </Value>
306
+ </SettingsRow>
307
+ ))}
308
+ </SettingsCard>
309
+ </SettingsSection>
310
+ );
311
+ }
312
+
313
+ function NewProvider({
314
+ templates,
315
+ existing,
316
+ busy,
317
+ run,
318
+ onCreated,
319
+ }: PartProps & {
320
+ templates: readonly ProviderTemplate[];
321
+ existing: readonly CustomProvider[];
322
+ onCreated: (providerId: string | null) => void;
323
+ }) {
324
+ const [templateId, setTemplateId] = useState(CUSTOM);
325
+ const template = templates.find((entry) => entry.templateId === templateId);
326
+ const [apiFormat, setApiFormat] = useState<ApiFormat>(
327
+ "openai-chat-completions",
328
+ );
329
+ const fields = useRef({ name: "", baseUrl: "", apiKey: "" });
330
+ const [error, setError] = useState<string | null>(null);
331
+
332
+ const choose = (id: string) => {
333
+ const next = templates.find((entry) => entry.templateId === id);
334
+ setTemplateId(id);
335
+ setApiFormat(next?.apiFormat ?? "openai-chat-completions");
336
+ fields.current = {
337
+ name: next?.name ?? "",
338
+ baseUrl: next?.baseUrl ?? "",
339
+ apiKey: fields.current.apiKey,
340
+ };
341
+ };
342
+ const create = async () => {
343
+ const name = fields.current.name.trim();
344
+ const baseUrl = fields.current.baseUrl.trim();
345
+ const apiKey = fields.current.apiKey.trim();
346
+ if (!name) return setError("Enter a name.");
347
+ if (!isHttpUrl(baseUrl)) return setError("Enter an http(s) base URL.");
348
+ if (!apiKey) return setError("Enter an API key.");
349
+ setError(null);
350
+ const next = await run({
351
+ action: "createProvider",
352
+ ...(template ? { templateId: template.templateId } : {}),
353
+ name,
354
+ apiFormat,
355
+ baseUrl,
356
+ apiKey,
357
+ });
358
+ if (next?.status !== "ready") return;
359
+ const before = new Set(existing.map((entry) => entry.providerId));
360
+ onCreated(
361
+ next.providers.find((entry) => !before.has(entry.providerId))
362
+ ?.providerId ?? null,
363
+ );
364
+ };
365
+
366
+ return (
367
+ <SettingsSection title="New provider">
368
+ <SettingsCard>
369
+ <SettingsSelect
370
+ label="Template"
371
+ hint="Templates fill in the endpoint and known models."
372
+ value={templateId}
373
+ options={[
374
+ { label: "Custom", value: CUSTOM },
375
+ ...templates.map((entry) => ({
376
+ label: entry.name,
377
+ value: entry.templateId,
378
+ })),
379
+ ]}
380
+ onValueChange={choose}
381
+ disabled={busy}
382
+ />
383
+ <SettingsInput
384
+ key={`name-${templateId}`}
385
+ label="Name"
386
+ initialValue={template?.name ?? ""}
387
+ placeholder="CommandCode"
388
+ onChangeText={(text) => (fields.current.name = text)}
389
+ disabled={busy}
390
+ />
391
+ <SettingsInput
392
+ key={`url-${templateId}`}
393
+ label="Base URL"
394
+ initialValue={template?.baseUrl ?? ""}
395
+ placeholder="https://api.example.com/v1"
396
+ onChangeText={(text) => (fields.current.baseUrl = text)}
397
+ disabled={busy}
398
+ />
399
+ <SettingsSelect<ApiFormat>
400
+ label="API format"
401
+ value={apiFormat}
402
+ options={API_FORMATS}
403
+ onValueChange={setApiFormat}
404
+ disabled={busy}
405
+ />
406
+ <SettingsInput
407
+ label="API key"
408
+ hint="Stored by ZCode on the daemon machine. It is never shown again."
409
+ secureTextEntry
410
+ onChangeText={(text) => (fields.current.apiKey = text)}
411
+ disabled={busy}
412
+ />
413
+ {template?.apiKeyManagementUrl ? (
414
+ <SettingsAction
415
+ label="Get an API key"
416
+ hint={template.apiKeyManagementUrl}
417
+ actionLabel="Open"
418
+ onPress={() => void openUrl(template.apiKeyManagementUrl!)}
419
+ />
420
+ ) : null}
421
+ <SettingsAction
422
+ label="Create provider"
423
+ hint={
424
+ template
425
+ ? "The template's models are added automatically."
426
+ : "Add models after creating the provider."
427
+ }
428
+ error={error}
429
+ actionLabel="Create"
430
+ disabled={busy}
431
+ onPress={() => void create()}
432
+ />
433
+ </SettingsCard>
434
+ </SettingsSection>
435
+ );
436
+ }
437
+
438
+ function ProviderEditor({
439
+ provider,
440
+ busy,
441
+ run,
442
+ onDeleted,
443
+ }: PartProps & { provider: CustomProvider; onDeleted: () => void }) {
444
+ const fields = useRef({
445
+ name: provider.name,
446
+ baseUrl: provider.baseUrl ?? "",
447
+ apiKey: "",
448
+ modelId: "",
449
+ });
450
+ const [apiFormat, setApiFormat] = useState<ApiFormat>(
451
+ provider.apiFormat ?? "openai-chat-completions",
452
+ );
453
+ // Remount counters that clear the API key and model inputs after success.
454
+ const [keyInput, setKeyInput] = useState(0);
455
+ const [modelInput, setModelInput] = useState(0);
456
+ const [error, setError] = useState<string | null>(null);
457
+ const [modelError, setModelError] = useState<string | null>(null);
458
+ const [confirmDelete, setConfirmDelete] = useState(false);
459
+ const added = provider.models.filter((model) => !model.builtin);
460
+ const [removal, setRemoval] = useState(added[0]?.modelId ?? "");
461
+ const id = provider.providerId;
462
+
463
+ const save = async () => {
464
+ const name = fields.current.name.trim();
465
+ const baseUrl = fields.current.baseUrl.trim();
466
+ const apiKey = fields.current.apiKey.trim();
467
+ if (!name) return setError("Enter a name.");
468
+ if (!isHttpUrl(baseUrl)) return setError("Enter an http(s) base URL.");
469
+ setError(null);
470
+ const next = await run({
471
+ action: "updateProvider",
472
+ providerId: id,
473
+ ...(name === provider.name ? {} : { name }),
474
+ ...(baseUrl === (provider.baseUrl ?? "") ? {} : { baseUrl }),
475
+ ...(apiFormat === provider.apiFormat ? {} : { apiFormat }),
476
+ ...(apiKey ? { apiKey } : {}),
477
+ });
478
+ if (next === null) return;
479
+ fields.current.apiKey = "";
480
+ setKeyInput((value) => value + 1);
481
+ };
482
+ const addModel = async () => {
483
+ const modelId = fields.current.modelId.trim();
484
+ if (!modelId) return setModelError("Enter a model ID.");
485
+ if (provider.models.some((model) => model.modelId === modelId))
486
+ return setModelError("The model is already listed.");
487
+ setModelError(null);
488
+ if ((await run({ action: "addModel", providerId: id, modelId })) === null)
489
+ return;
490
+ fields.current.modelId = "";
491
+ setRemoval((current) => current || modelId);
492
+ setModelInput((value) => value + 1);
493
+ };
494
+ const removeModel = async () => {
495
+ const next = await run({
496
+ action: "deleteModel",
497
+ providerId: id,
498
+ modelId: removal,
499
+ });
500
+ if (next?.status !== "ready") return;
501
+ const models =
502
+ next.providers.find((entry) => entry.providerId === id)?.models ?? [];
503
+ setRemoval(models.find((model) => !model.builtin)?.modelId ?? "");
504
+ };
505
+
506
+ return (
507
+ <>
508
+ <SettingsSection title={provider.name}>
509
+ <SettingsCard>
510
+ <SettingsSwitch
511
+ label="Enabled"
512
+ hint={providerStatus(provider)}
513
+ value={provider.enabled}
514
+ onValueChange={(enabled) =>
515
+ void run({ action: "updateProvider", providerId: id, enabled })
516
+ }
517
+ disabled={busy}
518
+ />
519
+ <SettingsInput
520
+ label="Name"
521
+ initialValue={provider.name}
522
+ onChangeText={(text) => (fields.current.name = text)}
523
+ disabled={busy}
524
+ />
525
+ <SettingsInput
526
+ label="Base URL"
527
+ initialValue={provider.baseUrl ?? ""}
528
+ placeholder="https://api.example.com/v1"
529
+ onChangeText={(text) => (fields.current.baseUrl = text)}
530
+ disabled={busy}
531
+ />
532
+ <SettingsSelect<ApiFormat>
533
+ label="API format"
534
+ value={apiFormat}
535
+ options={API_FORMATS}
536
+ onValueChange={setApiFormat}
537
+ disabled={busy}
538
+ />
539
+ <SettingsInput
540
+ key={`key-${keyInput}`}
541
+ label="API key"
542
+ hint={
543
+ provider.apiKeySet
544
+ ? "Saved. Enter a new key to replace it."
545
+ : "Required."
546
+ }
547
+ placeholder={provider.apiKeySet ? "••••••••" : undefined}
548
+ secureTextEntry
549
+ onChangeText={(text) => (fields.current.apiKey = text)}
550
+ disabled={busy}
551
+ />
552
+ {provider.apiKeyManagementUrl ? (
553
+ <SettingsAction
554
+ label="Get an API key"
555
+ hint={provider.apiKeyManagementUrl}
556
+ actionLabel="Open"
557
+ onPress={() => void openUrl(provider.apiKeyManagementUrl!)}
558
+ />
559
+ ) : null}
560
+ <SettingsAction
561
+ label="Save changes"
562
+ error={error}
563
+ actionLabel="Save"
564
+ disabled={busy}
565
+ onPress={() => void save()}
566
+ />
567
+ </SettingsCard>
568
+ </SettingsSection>
569
+ <SettingsSection title="Models">
570
+ <SettingsCard>
571
+ {provider.models.map((model) => (
572
+ <SettingsSwitch
573
+ key={model.modelId}
574
+ label={model.modelId}
575
+ hint={modelHint(model)}
576
+ value={model.enabled}
577
+ onValueChange={(enabled) =>
578
+ void run({
579
+ action: "setModelEnabled",
580
+ providerId: id,
581
+ modelId: model.modelId,
582
+ enabled,
583
+ })
584
+ }
585
+ disabled={busy}
586
+ />
587
+ ))}
588
+ <SettingsInput
589
+ key={`model-${modelInput}`}
590
+ label="Model ID"
591
+ hint="As the provider names it, e.g. deepseek/deepseek-v4.1-flash."
592
+ onChangeText={(text) => (fields.current.modelId = text)}
593
+ disabled={busy}
594
+ />
595
+ <SettingsAction
596
+ label="Add model"
597
+ hint="ZCode applies known settings for recognised models."
598
+ error={modelError}
599
+ actionLabel="Add"
600
+ disabled={busy}
601
+ onPress={() => void addModel()}
602
+ />
603
+ {added.length === 0
604
+ ? null
605
+ : [
606
+ <SettingsSelect
607
+ key="removal"
608
+ label="Model to remove"
609
+ value={removal}
610
+ options={added.map((model) => ({
611
+ label: model.modelId,
612
+ value: model.modelId,
613
+ }))}
614
+ onValueChange={setRemoval}
615
+ disabled={busy}
616
+ />,
617
+ <SettingsAction
618
+ key="remove"
619
+ label="Remove model"
620
+ hint="Template models can only be turned off."
621
+ actionLabel="Remove"
622
+ disabled={busy || !removal}
623
+ onPress={() => void removeModel()}
624
+ />,
625
+ ]}
626
+ </SettingsCard>
627
+ </SettingsSection>
628
+ <SettingsSection title="Delete provider">
629
+ <SettingsAction
630
+ label={provider.name}
631
+ hint="Removes the provider, its API key and its models from ZCode."
632
+ actionLabel={confirmDelete ? "Confirm deletion" : "Delete"}
633
+ disabled={busy}
634
+ onPress={() => {
635
+ if (!confirmDelete) {
636
+ setConfirmDelete(true);
637
+ return;
638
+ }
639
+ setConfirmDelete(false);
640
+ void run({ action: "deleteProvider", providerId: id }).then(
641
+ (next) => {
642
+ if (next !== null) onDeleted();
643
+ },
644
+ );
645
+ }}
646
+ />
647
+ </SettingsSection>
648
+ </>
649
+ );
650
+ }
651
+
652
+ function Providers(props: PartProps & { view: ReadyAccountView }) {
653
+ const { view } = props;
654
+ const [editing, setEditing] = useState<string | null>(null);
655
+ const provider = view.providers.find((entry) => entry.providerId === editing);
656
+ const toggle = (id: string) =>
657
+ setEditing((current) => (current === id ? null : id));
658
+
659
+ return (
660
+ <>
661
+ <SettingsSection title="Model providers">
662
+ <SettingsCard>
663
+ {view.providers.map((entry) => (
664
+ <SettingsAction
665
+ key={entry.providerId}
666
+ label={entry.name}
667
+ hint={`${providerStatus(entry)} · ${entry.models.filter((model) => model.enabled).length} of ${entry.models.length} models on`}
668
+ actionLabel={editing === entry.providerId ? "Close" : "Edit"}
669
+ onPress={() => toggle(entry.providerId)}
670
+ />
671
+ ))}
672
+ <SettingsAction
673
+ label="Add provider"
674
+ hint="Use an API key from Z.ai, DeepSeek, OpenRouter or any compatible endpoint."
675
+ actionLabel={editing === CUSTOM ? "Close" : "Add"}
676
+ onPress={() => toggle(CUSTOM)}
677
+ />
678
+ </SettingsCard>
679
+ </SettingsSection>
680
+ {editing === CUSTOM ? (
681
+ <NewProvider
682
+ {...props}
683
+ templates={view.templates}
684
+ existing={view.providers}
685
+ onCreated={setEditing}
686
+ />
687
+ ) : provider ? (
688
+ <ProviderEditor
689
+ {...props}
690
+ key={provider.providerId}
691
+ provider={provider}
692
+ onDeleted={() => setEditing(null)}
693
+ />
694
+ ) : null}
695
+ </>
696
+ );
697
+ }
698
+
699
+ export function AccountTab({
700
+ theme,
701
+ layout,
702
+ host,
703
+ onOpenRuntime,
704
+ }: PluginSurfaceProps & { onOpenRuntime: () => void }) {
705
+ const fetchView = useRpc(zcodeAccountView);
706
+ const change = useRpc(zcodeAccountChange);
707
+ const [view, setView] = useState<AccountView | null>(null);
708
+ const [requestError, setRequestError] = useState<string | null>(null);
709
+ const [pending, setPending] = useState(false);
710
+ const compact = layout.compact;
711
+
712
+ const refresh = useCallback(async () => {
713
+ try {
714
+ setView(await fetchView({}));
715
+ setRequestError(null);
716
+ } catch (cause) {
717
+ setRequestError(failure(cause));
718
+ }
719
+ }, [fetchView]);
720
+
721
+ useEffect(() => {
722
+ void refresh();
723
+ }, [refresh]);
724
+
725
+ const waiting = view?.status === "ready" && view.signIn.state === "pending";
726
+ useEffect(() => {
727
+ if (!waiting) return;
728
+ const timer = setInterval(() => void refresh(), 2000);
729
+ return () => clearInterval(timer);
730
+ }, [waiting, refresh]);
731
+
732
+ // Sign-in completes on the daemon; refresh models once it reports success.
733
+ const wasWaiting = useRef(false);
734
+ const signedIn =
735
+ view?.status === "ready" && view.account.state === "signed-in";
736
+ useEffect(() => {
737
+ if (wasWaiting.current && !waiting && signedIn) refreshCatalog(host.id);
738
+ wasWaiting.current = waiting;
739
+ }, [waiting, signedIn, host.id]);
740
+
741
+ const run = useCallback<Run>(
742
+ async (input) => {
743
+ setPending(true);
744
+ try {
745
+ const next = await change(input);
746
+ setView(next);
747
+ setRequestError(null);
748
+ if (input.action !== "signIn" && input.action !== "cancelSignIn")
749
+ refreshCatalog(host.id);
750
+ return next;
751
+ } catch (cause) {
752
+ setRequestError(failure(cause));
753
+ return null;
754
+ } finally {
755
+ setPending(false);
756
+ }
757
+ },
758
+ [change, host.id],
759
+ );
760
+
761
+ const error =
762
+ requestError === null ? null : (
763
+ <SettingsSection title="Last request">
764
+ <Text
765
+ accessibilityRole="alert"
766
+ style={{
767
+ color: theme.colors.statusDanger,
768
+ fontSize: compact ? 13 : 14,
769
+ }}
770
+ >
771
+ {requestError}
772
+ </Text>
773
+ </SettingsSection>
774
+ );
775
+
776
+ if (view === null || view.status === "unavailable")
777
+ return (
778
+ <View style={{ gap: compact ? 16 : 24 }}>
779
+ <SettingsSection title="ZCode account">
780
+ <SettingsCard>
781
+ <SettingsRow label="Status">
782
+ <Value
783
+ color={
784
+ view === null
785
+ ? theme.colors.foregroundMuted
786
+ : theme.colors.statusWarning
787
+ }
788
+ compact={compact}
789
+ >
790
+ {view === null
791
+ ? requestError === null
792
+ ? "Starting ZCode…"
793
+ : "ZCode could not be reached."
794
+ : view.message}
795
+ </Value>
796
+ </SettingsRow>
797
+ {view?.code === "RUNTIME_SETUP_REQUIRED" ? (
798
+ <SettingsAction
799
+ label="ZCode runtime"
800
+ hint="Install it before signing in or adding providers."
801
+ actionLabel="Open Runtime"
802
+ onPress={onOpenRuntime}
803
+ />
804
+ ) : view === null && requestError === null ? null : (
805
+ <SettingsAction
806
+ label="Retry"
807
+ actionLabel="Retry"
808
+ disabled={pending}
809
+ onPress={() => void refresh()}
810
+ />
811
+ )}
812
+ </SettingsCard>
813
+ </SettingsSection>
814
+ {error}
815
+ </View>
816
+ );
817
+
818
+ const parts = { theme, compact, busy: pending, run };
819
+ return (
820
+ <View style={{ gap: compact ? 16 : 24 }}>
821
+ <Account {...parts} view={view} />
822
+ <Plans view={view} theme={theme} compact={compact} />
823
+ <Providers {...parts} view={view} />
824
+ {error}
825
+ </View>
826
+ );
827
+ }