@raingor/pi-web-switch 0.3.1 → 0.3.2

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.
@@ -1,32 +1,161 @@
1
- import { useState, useRef } from "react";
1
+ import { useState, useRef, useEffect } from "react";
2
2
  import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
- import { Badge } from "@/components/ui/Badge";
5
4
  import { Modal } from "@/components/ui/Modal";
6
5
  import { exportConfig, parseImportFile, saveLocalBackup } from "@/lib/config";
7
- import type { PiConfig } from "@/types";
6
+ import type { PiConfig, UpdateCheckResult } from "@/types";
8
7
  import { cn } from "@/lib/utils";
9
8
  import {
10
- Settings,
11
9
  Download,
12
10
  Upload,
13
11
  RotateCcw,
14
- Sun,
15
- Moon,
16
- Monitor,
17
12
  Package,
18
13
  Plus,
19
- Trash2,
20
- CheckCircle2,
14
+ X,
15
+ Check,
16
+ Palette,
17
+ Type,
18
+ LayoutGrid,
19
+ Wrench,
20
+ Settings as SettingsIcon,
21
+ Zap,
22
+ CloudDownload,
23
+ RefreshCw,
21
24
  } from "lucide-react";
22
25
 
26
+ type SettingsTab = "appearance" | "models" | "advanced";
27
+
28
+ // Visual palette previews for the theme swatch picker (mirrors pi-desktop).
29
+ const THEME_SWATCHES: {
30
+ value: "dark" | "light" | "light/dark";
31
+ labelKey: string;
32
+ bg: string;
33
+ dots: string[];
34
+ }[] = [
35
+ { value: "dark", labelKey: "settings.dark", bg: "linear-gradient(145deg, #14141c, #0a0a0f)", dots: ["#00d4aa", "#7c5cfc", "#2a2a35"] },
36
+ { value: "light", labelKey: "settings.light", bg: "linear-gradient(145deg, #ffffff, #e9ecf2)", dots: ["#00b894", "#7c5cfc", "#c9ced8"] },
37
+ { value: "light/dark", labelKey: "settings.system", bg: "linear-gradient(105deg, #0a0a0f 49.5%, #f1f2f6 50.5%)", dots: ["#00d4aa", "#7c5cfc", "#9aa0ab"] },
38
+ ];
39
+
40
+ const FONT_SIZE_KEY = "pi-font-size";
41
+
42
+ function Card({
43
+ icon: Icon,
44
+ title,
45
+ desc,
46
+ children,
47
+ }: {
48
+ icon: typeof Palette;
49
+ title: string;
50
+ desc?: string;
51
+ children: React.ReactNode;
52
+ }) {
53
+ return (
54
+ <section className="rounded-xl border border-gray-800 bg-gray-900/50">
55
+ <div className="flex items-center gap-3 border-b border-gray-800 px-6 py-4">
56
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg border border-gray-700 bg-gray-800">
57
+ <Icon className="h-4 w-4 text-blue-400" />
58
+ </div>
59
+ <div>
60
+ <h2 className="text-sm font-semibold text-gray-300">{title}</h2>
61
+ {desc && <p className="text-xs text-gray-500">{desc}</p>}
62
+ </div>
63
+ </div>
64
+ <div className="p-6">{children}</div>
65
+ </section>
66
+ );
67
+ }
68
+
69
+ function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
70
+ return (
71
+ <div className="flex items-center justify-between gap-4 border-b border-gray-800/60 py-3 last:border-b-0 last:pb-0 first:pt-0">
72
+ <span className="text-sm text-gray-400">{label}</span>
73
+ {children}
74
+ </div>
75
+ );
76
+ }
77
+
23
78
  export function SettingsPage() {
24
79
  const { t } = useTranslation();
25
- const { settings, auth, modelsJson, allProviders, allModels, updateSettings, setTheme, addPackage, removePackage, importConfig: importConfigAction, resetToDefaults } = useConfigStore();
80
+ const {
81
+ settings,
82
+ auth,
83
+ modelsJson,
84
+ allProviders,
85
+ allModels,
86
+ updateSettings,
87
+ setTheme,
88
+ addEnabledModel,
89
+ removeEnabledModel,
90
+ addPackage,
91
+ removePackage,
92
+ importConfig: importConfigAction,
93
+ resetToDefaults,
94
+ } = useConfigStore();
26
95
  const fileInputRef = useRef<HTMLInputElement>(null);
96
+ const [activeTab, setActiveTab] = useState<SettingsTab>("appearance");
27
97
  const [newPackage, setNewPackage] = useState("");
28
98
  const [importError, setImportError] = useState("");
29
99
  const [showResetConfirm, setShowResetConfirm] = useState(false);
100
+ const [fontSize, setFontSize] = useState(() => {
101
+ const saved = Number(localStorage.getItem(FONT_SIZE_KEY));
102
+ return saved >= 12 && saved <= 24 ? saved : 16;
103
+ });
104
+ // pi core / extensions update check (Advanced tab).
105
+ const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null);
106
+ const [checkingUpdates, setCheckingUpdates] = useState(false);
107
+ const [updateError, setUpdateError] = useState(false);
108
+ const [applying, setApplying] = useState(false);
109
+ const [applyMessage, setApplyMessage] = useState<{ ok: number; failNames: string[] } | null>(null);
110
+
111
+ const handleCheckUpdates = async () => {
112
+ setCheckingUpdates(true);
113
+ setUpdateError(false);
114
+ setApplyMessage(null);
115
+ try {
116
+ const res = await fetch("/api/pi/check-updates");
117
+ if (!res.ok) throw new Error("check failed");
118
+ setUpdateResult(await res.json());
119
+ } catch {
120
+ setUpdateError(true);
121
+ } finally {
122
+ setCheckingUpdates(false);
123
+ }
124
+ };
125
+
126
+ // One-click update: npm install <name>@latest for every updatable extension,
127
+ // then re-check so the list reflects the new installed versions.
128
+ const handleApplyUpdates = async () => {
129
+ const names = (updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name);
130
+ if (names.length === 0) return;
131
+ setApplying(true);
132
+ setApplyMessage(null);
133
+ try {
134
+ const res = await fetch("/api/pi/apply-updates", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ names }),
138
+ });
139
+ if (!res.ok) throw new Error("apply failed");
140
+ const { results } = (await res.json()) as {
141
+ results: { name: string; success: boolean; message?: string }[];
142
+ };
143
+ const failNames = results.filter((r) => !r.success).map((r) => r.name);
144
+ setApplyMessage({ ok: results.length - failNames.length, failNames });
145
+ const check = await fetch("/api/pi/check-updates");
146
+ if (check.ok) setUpdateResult(await check.json());
147
+ } catch {
148
+ setUpdateError(true);
149
+ } finally {
150
+ setApplying(false);
151
+ }
152
+ };
153
+
154
+ // Apply + persist UI font size (root rem scaling).
155
+ useEffect(() => {
156
+ document.documentElement.style.fontSize = `${fontSize}px`;
157
+ localStorage.setItem(FONT_SIZE_KEY, String(fontSize));
158
+ }, [fontSize]);
30
159
 
31
160
  const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
32
161
  const file = e.target.files?.[0];
@@ -38,301 +167,484 @@ export function SettingsPage() {
38
167
  await importConfigAction(result);
39
168
  setImportError("");
40
169
  } else {
41
- setImportError("Invalid config file format");
170
+ setImportError(t("settings.import_error"));
42
171
  }
43
172
  };
44
173
  reader.readAsText(file);
45
174
  };
46
175
 
47
- const buildConfigForExport = (): PiConfig => ({
48
- settings: settings ?? { theme: "dark", packages: [], enabledModels: [] },
49
- auth: auth ?? {},
50
- modelsJson: modelsJson ?? { providers: {} },
51
- });
52
-
53
176
  const handleExport = () => {
54
- const cfg = buildConfigForExport();
177
+ const cfg: PiConfig = {
178
+ settings: settings ?? { theme: "dark", packages: [], enabledModels: [] },
179
+ auth: auth ?? {},
180
+ modelsJson: modelsJson ?? { providers: {} },
181
+ };
55
182
  saveLocalBackup(cfg);
56
183
  exportConfig(cfg);
57
184
  };
58
185
 
59
- const providerOptions = allProviders.map((p) => ({
60
- value: p.id,
61
- label: p.name,
62
- }));
186
+ // ── Default model select: composite `providerId/modelId` values, but the
187
+ // settings file stores the bare model id (that's what pi expects on disk).
188
+ const modelValue = (providerId: string, modelId: string) => `${providerId}/${modelId}`;
189
+ const modelProviders = allProviders.filter((p) => p.models.length > 0);
190
+ const scopedId = settings?.defaultProvider;
191
+ const groupedModelOptions = scopedId
192
+ ? [...modelProviders.filter((p) => p.id === scopedId), ...modelProviders.filter((p) => p.id !== scopedId)]
193
+ : modelProviders;
194
+
195
+ const selectedModelValue = (() => {
196
+ const id = settings?.defaultModel;
197
+ if (!id) return "";
198
+ const dp = settings?.defaultProvider;
199
+ if (dp && allProviders.find((p) => p.id === dp)?.models.some((m) => m.id === id)) {
200
+ return modelValue(dp, id);
201
+ }
202
+ const owner = allProviders.find((p) => p.models.some((m) => m.id === id));
203
+ return owner ? modelValue(owner.id, id) : id; // stale ids display as-is
204
+ })();
205
+
206
+ const handleDefaultModelChange = (v: string) => {
207
+ if (!v) {
208
+ updateSettings({ defaultModel: undefined });
209
+ return;
210
+ }
211
+ const idx = v.indexOf("/"); // model ids may themselves contain '/'
212
+ const providerId = idx >= 0 ? v.slice(0, idx) : "";
213
+ const modelId = idx >= 0 ? v.slice(idx + 1) : v;
214
+ // Keep the provider in sync so the (provider, model) pair stays consistent.
215
+ updateSettings(providerId ? { defaultProvider: providerId, defaultModel: modelId } : { defaultModel: modelId });
216
+ };
217
+
218
+ const handleDefaultProviderChange = (v: string) => {
219
+ // Drop a default model that doesn't belong to the newly picked provider.
220
+ let clearModel = false;
221
+ if (v && settings?.defaultModel) {
222
+ const provider = allProviders.find((p) => p.id === v);
223
+ if (provider && !provider.models.some((m) => m.id === settings.defaultModel)) {
224
+ clearModel = true;
225
+ }
226
+ }
227
+ updateSettings(clearModel ? { defaultProvider: v, defaultModel: undefined } : { defaultProvider: v || undefined });
228
+ };
229
+
230
+ // Display names that appear under several providers need the provider name
231
+ // appended to stay distinguishable.
232
+ const modelNameCounts = allModels.reduce<Record<string, number>>((acc, m) => {
233
+ const name = m.name || m.id;
234
+ acc[name] = (acc[name] ?? 0) + 1;
235
+ return acc;
236
+ }, {});
237
+ const modelTagLabel = (m: (typeof allModels)[number]) => {
238
+ const name = m.name || m.id;
239
+ return (modelNameCounts[name] ?? 0) > 1 ? `${name} · ${m.providerName}` : name;
240
+ };
241
+
242
+ const enabledModels = settings?.enabledModels ?? [];
243
+ const themeLabelKey: Record<string, string> = {
244
+ light: "settings.light",
245
+ dark: "settings.dark",
246
+ "light/dark": "settings.system",
247
+ };
63
248
 
64
- const modelOptions = allModels.map((m) => ({
65
- value: `${m.providerId}/${m.id}`,
66
- label: `${m.providerName} / ${m.name || m.id}`,
67
- }));
249
+ const tabs: { key: SettingsTab; icon: typeof Palette; label: string }[] = [
250
+ { key: "appearance", icon: Palette, label: t("settings.appearance") },
251
+ { key: "models", icon: LayoutGrid, label: t("settings.tab_models") },
252
+ { key: "advanced", icon: Wrench, label: t("settings.tab_advanced") },
253
+ ];
254
+
255
+ const selectCls =
256
+ "rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white";
68
257
 
69
258
  return (
70
259
  <div className="space-y-6">
71
- <div>
72
- <h1 className="text-2xl font-bold text-white">Settings</h1>
73
- <p className="mt-1 text-sm text-gray-400">Configure pi agent preferences</p>
74
- </div>
260
+ {/* ── Hero ─────────────────────────────────────────── */}
261
+ <header>
262
+ <div className="text-[11px] font-semibold uppercase tracking-widest text-blue-400">
263
+ pi · workspace
264
+ </div>
265
+ <h1 className="mt-1 text-2xl font-bold text-white">{t("settings.title")}</h1>
266
+ <p className="mt-1 text-sm text-gray-400">{t("settings.subtitle")}</p>
267
+ <div className="mt-4 grid max-w-lg grid-cols-3 gap-3">
268
+ <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
269
+ <div className="text-xl font-bold text-white">{allProviders.length}</div>
270
+ <div className="text-xs text-gray-500">{t("settings.stat_providers")}</div>
271
+ </div>
272
+ <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
273
+ <div className="text-xl font-bold text-white">
274
+ {enabledModels.length}
275
+ <small className="ml-0.5 text-xs font-normal text-gray-500">/ {allModels.length}</small>
276
+ </div>
277
+ <div className="text-xs text-gray-500">{t("settings.stat_enabled")}</div>
278
+ </div>
279
+ <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
280
+ <div className="pt-1 text-sm font-bold text-white">
281
+ {t(themeLabelKey[settings?.theme ?? "light/dark"] ?? "settings.system")}
282
+ </div>
283
+ <div className="mt-0.5 text-xs text-gray-500">{t("settings.stat_theme")}</div>
284
+ </div>
285
+ </div>
286
+ </header>
287
+
288
+ {/* ── Tab nav ──────────────────────────────────────── */}
289
+ <nav className="flex gap-2 border-b border-gray-800 pb-3">
290
+ {tabs.map(({ key, icon: Icon, label }) => (
291
+ <button
292
+ key={key}
293
+ onClick={() => setActiveTab(key)}
294
+ className={cn(
295
+ "flex items-center gap-2 rounded-full border px-4 py-1.5 text-sm transition-all",
296
+ activeTab === key
297
+ ? "border-blue-500 bg-blue-500/10 text-blue-400"
298
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
299
+ )}
300
+ >
301
+ <Icon className="h-3.5 w-3.5" />
302
+ {label}
303
+ </button>
304
+ ))}
305
+ </nav>
75
306
 
76
- {/* Default Provider/Model */}
77
- <section className="rounded-xl border border-gray-800 bg-gray-900/50">
78
- <div className="border-b border-gray-800 px-6 py-4">
79
- <h2 className="text-sm font-semibold text-gray-300">Defaults</h2>
307
+ {/* ── Appearance ───────────────────────────────────── */}
308
+ {activeTab === "appearance" && (
309
+ <div className="space-y-6">
310
+ <Card icon={Palette} title={t("settings.theme")} desc={t("settings.theme_desc")}>
311
+ <div className="grid grid-cols-3 gap-3 max-w-xl">
312
+ {THEME_SWATCHES.map((s) => {
313
+ const active = (settings?.theme ?? "light/dark") === s.value;
314
+ return (
315
+ <button
316
+ key={s.value}
317
+ onClick={() => setTheme(s.value)}
318
+ className={cn(
319
+ "overflow-hidden rounded-xl border text-left transition-all",
320
+ active ? "border-blue-500 ring-1 ring-blue-500/40" : "border-gray-700 hover:border-gray-600"
321
+ )}
322
+ >
323
+ <div
324
+ className="flex h-16 items-end gap-1.5 p-2.5"
325
+ style={{ background: s.bg }}
326
+ >
327
+ {s.dots.map((d) => (
328
+ <span key={d} className="h-2.5 w-2.5 rounded-full" style={{ background: d }} />
329
+ ))}
330
+ </div>
331
+ <div className="flex items-center justify-between bg-gray-800 px-3 py-2">
332
+ <span className={cn("text-xs font-medium", active ? "text-blue-400" : "text-gray-400")}>
333
+ {t(s.labelKey)}
334
+ </span>
335
+ {active && <Check className="h-3.5 w-3.5 text-blue-400" />}
336
+ </div>
337
+ </button>
338
+ );
339
+ })}
340
+ </div>
341
+ <div className="mt-5 flex items-center gap-3">
342
+ <label className="flex items-center gap-2 text-sm text-gray-400">
343
+ <input
344
+ type="checkbox"
345
+ checked={settings?.hideThinkingBlock ?? false}
346
+ onChange={(e) => updateSettings({ hideThinkingBlock: e.target.checked })}
347
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
348
+ />
349
+ {t("settings.hide_thinking")}
350
+ </label>
351
+ </div>
352
+ </Card>
353
+
354
+ <Card icon={Type} title={t("settings.font_size")} desc={t("settings.font_size_desc")}>
355
+ <div className="flex max-w-xl items-center gap-4">
356
+ <input
357
+ type="range"
358
+ min={12}
359
+ max={24}
360
+ step={1}
361
+ value={fontSize}
362
+ onChange={(e) => setFontSize(Number(e.target.value))}
363
+ className="flex-1 accent-blue-500"
364
+ />
365
+ <span className="w-12 rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-center text-xs text-gray-300">
366
+ {fontSize}px
367
+ </span>
368
+ </div>
369
+ </Card>
80
370
  </div>
81
- <div className="space-y-4 p-6">
82
- <div className="grid gap-4 sm:grid-cols-2">
83
- <div>
84
- <label className="block text-xs font-medium text-gray-400">Default Provider</label>
371
+ )}
372
+
373
+ {/* ── Models ───────────────────────────────────────── */}
374
+ {activeTab === "models" && (
375
+ <div className="space-y-6">
376
+ <Card icon={SettingsIcon} title={t("settings.defaults")}>
377
+ <SettingRow label={t("settings.default_provider")}>
85
378
  <select
86
379
  value={settings?.defaultProvider ?? ""}
87
- onChange={(e) => updateSettings({ defaultProvider: e.target.value })}
88
- className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
380
+ onChange={(e) => handleDefaultProviderChange(e.target.value)}
381
+ className={cn(selectCls, "w-56")}
89
382
  >
90
- <option value="">— None —</option>
91
- {providerOptions.map((o) => (
92
- <option key={o.value} value={o.value}>{o.label}</option>
383
+ <option value="">{t("settings.none")}</option>
384
+ {allProviders.map((p) => (
385
+ <option key={p.id} value={p.id}>{p.name}</option>
93
386
  ))}
94
387
  </select>
95
- </div>
96
- <div>
97
- <label className="block text-xs font-medium text-gray-400">Default Model</label>
388
+ </SettingRow>
389
+ <SettingRow label={t("settings.default_model")}>
98
390
  <select
99
- value={settings?.defaultModel ?? ""}
100
- onChange={(e) => updateSettings({ defaultModel: e.target.value })}
101
- className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
391
+ value={selectedModelValue}
392
+ onChange={(e) => handleDefaultModelChange(e.target.value)}
393
+ className={cn(selectCls, "w-56")}
102
394
  >
103
- <option value="">— None —</option>
104
- {modelOptions.map((o) => (
105
- <option key={o.value} value={o.value}>{o.label}</option>
395
+ <option value="">{t("settings.none")}</option>
396
+ {groupedModelOptions.map((p) => (
397
+ <optgroup key={p.id} label={p.name}>
398
+ {p.models.map((m) => (
399
+ <option key={modelValue(p.id, m.id)} value={modelValue(p.id, m.id)}>
400
+ {(m.name || m.id) + " · " + p.name}
401
+ </option>
402
+ ))}
403
+ </optgroup>
106
404
  ))}
107
405
  </select>
108
- </div>
109
- </div>
110
- <div className="grid gap-4 sm:grid-cols-2">
111
- <div>
112
- <label className="block text-xs font-medium text-gray-400">Default Thinking Level</label>
406
+ </SettingRow>
407
+ <SettingRow label={t("settings.default_thinking")}>
113
408
  <select
114
409
  value={settings?.defaultThinkingLevel ?? "medium"}
115
410
  onChange={(e) => updateSettings({ defaultThinkingLevel: e.target.value })}
116
- className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
411
+ className={cn(selectCls, "w-56")}
117
412
  >
118
413
  {["off", "minimal", "low", "medium", "high", "xhigh"].map((l) => (
119
414
  <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>
120
415
  ))}
121
416
  </select>
122
- </div>
123
- <div>
124
- <label className="block text-xs font-medium text-gray-400">Project Trust</label>
417
+ </SettingRow>
418
+ <SettingRow label={t("settings.project_trust")}>
125
419
  <select
126
420
  value={settings?.defaultProjectTrust ?? "prompt"}
127
421
  onChange={(e) => updateSettings({ defaultProjectTrust: e.target.value })}
128
- className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
422
+ className={cn(selectCls, "w-56")}
129
423
  >
130
- {["prompt", "always", "never"].map((t) => (
131
- <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
424
+ {["prompt", "always", "never"].map((v) => (
425
+ <option key={v} value={v}>{v.charAt(0).toUpperCase() + v.slice(1)}</option>
132
426
  ))}
133
427
  </select>
428
+ </SettingRow>
429
+ </Card>
430
+
431
+ <Card icon={Zap} title={t("settings.enabled_models")} desc={t("settings.enabled_models_desc")}>
432
+ <div className="flex flex-wrap gap-1.5">
433
+ {allModels.map((m) => {
434
+ const ref = `${m.providerId}/${m.id}`;
435
+ const on = enabledModels.includes(ref);
436
+ return (
437
+ <button
438
+ key={ref}
439
+ onClick={() => (on ? removeEnabledModel(ref) : addEnabledModel(ref))}
440
+ className={cn(
441
+ "rounded-md border px-2.5 py-1 text-xs transition-colors",
442
+ on
443
+ ? "border-blue-500/50 bg-blue-500/10 text-blue-400"
444
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
445
+ )}
446
+ >
447
+ {modelTagLabel(m)}
448
+ </button>
449
+ );
450
+ })}
134
451
  </div>
135
- </div>
452
+ </Card>
136
453
  </div>
137
- </section>
454
+ )}
138
455
 
139
- {/* Theme */}
140
- <section className="rounded-xl border border-gray-800 bg-gray-900/50">
141
- <div className="border-b border-gray-800 px-6 py-4">
142
- <h2 className="text-sm font-semibold text-gray-300">Appearance</h2>
143
- </div>
144
- <div className="p-6">
145
- <div className="flex gap-3">
146
- {[
147
- { value: "light" as const, icon: Sun, label: "Light" },
148
- { value: "dark" as const, icon: Moon, label: "Dark" },
149
- { value: "light/dark" as const, icon: Monitor, label: "System" },
150
- ].map(({ value, icon: Icon, label }) => {
151
- const active = (settings?.theme ?? "light/dark") === value;
152
- return (
456
+ {/* ── Advanced ─────────────────────────────────────── */}
457
+ {activeTab === "advanced" && (
458
+ <div className="space-y-6">
459
+ <Card icon={CloudDownload} title={t("settings.updates_title")} desc={t("settings.updates_desc")}>
460
+ <div className="flex flex-wrap items-center gap-2">
461
+ <button
462
+ onClick={handleCheckUpdates}
463
+ disabled={checkingUpdates || applying}
464
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-60"
465
+ >
466
+ <RefreshCw className={cn("h-4 w-4", checkingUpdates && "animate-spin")} />
467
+ {t("settings.check_updates")}
468
+ </button>
469
+ {(updateResult?.extensions ?? []).some((e) => e.hasUpdate) && (
153
470
  <button
154
- key={value}
155
- onClick={() => setTheme(value)}
156
- className={cn(
157
- "flex items-center gap-2 rounded-lg border px-4 py-3 text-sm transition-all",
158
- active
159
- ? "border-blue-500 bg-blue-500/10 text-blue-400"
160
- : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
161
- )}
471
+ onClick={handleApplyUpdates}
472
+ disabled={applying || checkingUpdates}
473
+ className="flex items-center gap-2 rounded-lg border border-amber-500 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-60"
162
474
  >
163
- <Icon className="h-4 w-4" />
164
- {label}
165
- {active && <CheckCircle2 className="h-3.5 w-3.5 ml-1" />}
475
+ <CloudDownload className={cn("h-4 w-4", applying && "animate-pulse")} />
476
+ {applying ? t("settings.updating") : t("settings.update_all")}
166
477
  </button>
478
+ )}
479
+ </div>
480
+ {updateError && <p className="mt-3 text-sm text-red-400">{t("settings.updates_failed")}</p>}
481
+ {applyMessage && (
482
+ <div className="mt-3 space-y-1">
483
+ {applyMessage.ok > 0 && (
484
+ <p className="text-sm text-emerald-400">{t("settings.update_success", String(applyMessage.ok))}</p>
485
+ )}
486
+ {applyMessage.failNames.length > 0 && (
487
+ <p className="text-sm text-red-400">
488
+ {t("settings.update_failed_names", String(applyMessage.failNames.length), applyMessage.failNames.join(", "))}
489
+ </p>
490
+ )}
491
+ </div>
492
+ )}
493
+ {updateResult && (() => {
494
+ const rows = [...(updateResult.pi ? [updateResult.pi] : []), ...updateResult.extensions];
495
+ const updatable = rows.filter((r) => r.hasUpdate).length;
496
+ return (
497
+ <div className="mt-4 space-y-1.5">
498
+ <p className={cn("text-xs font-semibold", updatable > 0 ? "text-amber-400" : "text-emerald-400")}>
499
+ {updatable > 0
500
+ ? t("settings.updates_summary", String(updatable))
501
+ : t("settings.updates_all_latest")}
502
+ </p>
503
+ {rows.map((r) => (
504
+ <div
505
+ key={r.name}
506
+ className={cn(
507
+ "flex items-center justify-between gap-3 rounded-lg border bg-gray-800/30 px-3 py-2",
508
+ r.hasUpdate ? "border-amber-500/60" : "border-gray-800"
509
+ )}
510
+ >
511
+ <div className="flex min-w-0 items-center gap-2">
512
+ <span className="truncate font-mono text-xs text-gray-200">{r.name}</span>
513
+ {updateResult.pi && r.name === updateResult.pi.name && (
514
+ <span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
515
+ core
516
+ </span>
517
+ )}
518
+ </div>
519
+ <div className="flex shrink-0 items-center gap-2">
520
+ <span className="font-mono text-[11px] text-gray-500">
521
+ {r.latest === null ? `${r.installed} → ?` : r.hasUpdate ? `${r.installed} → ${r.latest}` : r.installed}
522
+ </span>
523
+ {r.latest === null ? (
524
+ <span className="rounded border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-500">
525
+ {t("settings.updates_lookup_failed")}
526
+ </span>
527
+ ) : r.hasUpdate ? (
528
+ <span className="rounded border border-amber-500 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">
529
+ {t("settings.update_available")}
530
+ </span>
531
+ ) : (
532
+ <span className="rounded border border-emerald-600/60 px-1.5 py-0.5 text-[10px] text-emerald-400">
533
+ {t("settings.updates_up_to_date")}
534
+ </span>
535
+ )}
536
+ </div>
537
+ </div>
538
+ ))}
539
+ {updatable > 0 && (
540
+ <p className="pt-1 text-[11px] text-gray-500">{t("settings.updates_hint")}</p>
541
+ )}
542
+ </div>
167
543
  );
168
- })}
169
- </div>
170
- <div className="mt-4 flex items-center gap-3">
171
- <label className="flex items-center gap-2 text-sm text-gray-400">
172
- <input
173
- type="checkbox"
174
- checked={settings?.hideThinkingBlock ?? false}
175
- onChange={(e) => updateSettings({ hideThinkingBlock: e.target.checked })}
176
- className="rounded border-gray-600 bg-gray-800 text-blue-500"
177
- />
178
- Hide thinking blocks
179
- </label>
180
- </div>
181
- </div>
182
- </section>
544
+ })()}
545
+ </Card>
183
546
 
184
- {/* Enabled Models */}
185
- <section className="rounded-xl border border-gray-800 bg-gray-900/50">
186
- <div className="border-b border-gray-800 px-6 py-4">
187
- <h2 className="text-sm font-semibold text-gray-300">Enabled Models</h2>
188
- </div>
189
- <div className="p-6">
190
- <div className="space-y-2">
191
- {(settings?.enabledModels ?? []).length === 0 ? (
192
- <p className="text-sm text-gray-500">No models enabled. Enable models from the Models page.</p>
193
- ) : (
194
- (settings?.enabledModels ?? []).map((ref) => (
195
- <div key={ref} className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/30 px-4 py-2.5">
196
- <div className="flex items-center gap-2">
197
- <CheckCircle2 className="h-4 w-4 text-emerald-400" />
198
- <span className="text-sm text-gray-200">{ref}</span>
199
- </div>
200
- <button
201
- onClick={() => useConfigStore.getState().removeEnabledModel(ref)}
202
- className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400"
547
+ <Card icon={Package} title={t("settings.packages")}>
548
+ {(settings?.packages ?? []).length > 0 && (
549
+ <div className="mb-4 flex flex-wrap gap-1.5">
550
+ {(settings?.packages ?? []).map((pkg) => (
551
+ <span
552
+ key={pkg}
553
+ className="flex items-center gap-1.5 rounded-md border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-300"
203
554
  >
204
- <Trash2 className="h-3.5 w-3.5" />
205
- </button>
206
- </div>
207
- ))
555
+ {pkg}
556
+ <button
557
+ onClick={() => removePackage(pkg)}
558
+ className="text-gray-500 hover:text-red-400"
559
+ >
560
+ <X className="h-3 w-3" />
561
+ </button>
562
+ </span>
563
+ ))}
564
+ </div>
208
565
  )}
209
- </div>
210
- </div>
211
- </section>
212
-
213
- {/* Packages */}
214
- <section className="rounded-xl border border-gray-800 bg-gray-900/50">
215
- <div className="border-b border-gray-800 px-6 py-4">
216
- <h2 className="text-sm font-semibold text-gray-300">Extensions & Packages</h2>
217
- </div>
218
- <div className="p-6 space-y-4">
219
- <div className="flex gap-2">
220
- <input
221
- type="text"
222
- value={newPackage}
223
- onChange={(e) => setNewPackage(e.target.value)}
224
- placeholder="npm:package-name"
225
- className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-500"
226
- onKeyDown={(e) => {
227
- if (e.key === "Enter" && newPackage) {
228
- addPackage(newPackage);
229
- setNewPackage("");
230
- }
231
- }}
232
- />
233
- <button
234
- onClick={() => {
235
- if (newPackage) {
236
- addPackage(newPackage);
237
- setNewPackage("");
238
- }
239
- }}
240
- className="flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-500"
241
- >
242
- <Plus className="h-4 w-4" />
243
- Add
244
- </button>
245
- </div>
246
- <div className="space-y-2">
247
- {(settings?.packages ?? []).length === 0 ? (
248
- <p className="text-sm text-gray-500">No packages installed</p>
249
- ) : (
250
- (settings?.packages ?? []).map((pkg) => (
251
- <div key={pkg} className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/30 px-4 py-2.5">
252
- <div className="flex items-center gap-2">
253
- <Package className="h-4 w-4 text-blue-400" />
254
- <span className="text-sm text-gray-200">{pkg}</span>
255
- </div>
256
- <button
257
- onClick={() => removePackage(pkg)}
258
- className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400"
259
- >
260
- <Trash2 className="h-3.5 w-3.5" />
261
- </button>
262
- </div>
263
- ))
566
+ {(settings?.packages ?? []).length === 0 && (
567
+ <p className="mb-4 text-sm text-gray-500">{t("settings.no_packages")}</p>
264
568
  )}
265
- </div>
266
- </div>
267
- </section>
268
-
269
- {/* Import/Export */}
270
- <section className="rounded-xl border border-gray-800 bg-gray-900/50">
271
- <div className="border-b border-gray-800 px-6 py-4">
272
- <h2 className="text-sm font-semibold text-gray-300">Import & Export</h2>
273
- </div>
274
- <div className="p-6 space-y-4">
275
- <div className="flex flex-wrap gap-3">
276
- <button
277
- onClick={handleExport}
278
- className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
279
- >
280
- <Download className="h-4 w-4" />
281
- Export Config
282
- </button>
283
- <button
284
- onClick={() => fileInputRef.current?.click()}
285
- className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
286
- >
287
- <Upload className="h-4 w-4" />
288
- Import Config
289
- </button>
290
- <input
291
- ref={fileInputRef}
292
- type="file"
293
- accept=".json"
294
- className="hidden"
295
- onChange={handleImport}
296
- />
297
- </div>
298
- {importError && <p className="text-sm text-red-400">{importError}</p>}
299
- </div>
300
- </section>
569
+ <div className="flex max-w-md gap-2">
570
+ <input
571
+ type="text"
572
+ value={newPackage}
573
+ onChange={(e) => setNewPackage(e.target.value)}
574
+ placeholder={t("settings.package_placeholder")}
575
+ className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500"
576
+ onKeyDown={(e) => {
577
+ if (e.key === "Enter" && newPackage.trim()) {
578
+ addPackage(newPackage.trim());
579
+ setNewPackage("");
580
+ }
581
+ }}
582
+ />
583
+ <button
584
+ onClick={() => {
585
+ if (newPackage.trim()) {
586
+ addPackage(newPackage.trim());
587
+ setNewPackage("");
588
+ }
589
+ }}
590
+ className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:bg-gray-700"
591
+ >
592
+ <Plus className="h-3.5 w-3.5" />
593
+ {t("settings.add")}
594
+ </button>
595
+ </div>
596
+ </Card>
301
597
 
302
- {/* Danger Zone */}
303
- <section className="rounded-xl border border-red-900/30 bg-red-950/20">
304
- <div className="border-b border-red-900/30 px-6 py-4">
305
- <h2 className="text-sm font-semibold text-red-400">Danger Zone</h2>
306
- </div>
307
- <div className="p-6">
308
- <button
309
- onClick={() => setShowResetConfirm(true)}
310
- className="flex items-center gap-2 rounded-lg border border-red-700 bg-red-900/30 px-4 py-2 text-sm text-red-400 hover:bg-red-900/50"
311
- >
312
- <RotateCcw className="h-4 w-4" />
313
- Reset to Defaults
314
- </button>
598
+ <Card icon={Download} title={t("settings.import_export")}>
599
+ <div className="flex flex-wrap gap-3">
600
+ <button
601
+ onClick={handleExport}
602
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
603
+ >
604
+ <Download className="h-4 w-4" />
605
+ {t("settings.export")}
606
+ </button>
607
+ <button
608
+ onClick={() => fileInputRef.current?.click()}
609
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:bg-gray-700"
610
+ >
611
+ <Upload className="h-4 w-4" />
612
+ {t("settings.import")}
613
+ </button>
614
+ <button
615
+ onClick={() => setShowResetConfirm(true)}
616
+ className="flex items-center gap-2 rounded-lg border border-red-700 bg-red-900/30 px-4 py-2 text-sm text-red-400 hover:bg-red-900/50"
617
+ >
618
+ <RotateCcw className="h-4 w-4" />
619
+ {t("settings.reset")}
620
+ </button>
621
+ <input
622
+ ref={fileInputRef}
623
+ type="file"
624
+ accept=".json"
625
+ className="hidden"
626
+ onChange={handleImport}
627
+ />
628
+ </div>
629
+ {importError && <p className="mt-3 text-sm text-red-400">{importError}</p>}
630
+ </Card>
315
631
  </div>
316
- </section>
632
+ )}
317
633
 
318
634
  {/* Reset Confirm */}
319
635
  <Modal
320
636
  open={showResetConfirm}
321
637
  onClose={() => setShowResetConfirm(false)}
322
- title="Reset Configuration?"
638
+ title={t("settings.reset_title")}
323
639
  >
324
640
  <div className="space-y-4">
325
- <p className="text-sm text-gray-400">
326
- This will reset all settings, auth keys, and custom providers to defaults.
327
- Changes are written directly to your pi config files (~/.pi/agent/).
328
- This action cannot be undone.
329
- </p>
641
+ <p className="text-sm text-gray-400">{t("settings.reset_confirm")}</p>
330
642
  <div className="flex justify-end gap-3">
331
643
  <button
332
644
  onClick={() => setShowResetConfirm(false)}
333
645
  className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
334
646
  >
335
- Cancel
647
+ {t("settings.cancel")}
336
648
  </button>
337
649
  <button
338
650
  onClick={() => {
@@ -341,11 +653,11 @@ export function SettingsPage() {
341
653
  }}
342
654
  className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500"
343
655
  >
344
- Reset
656
+ {t("settings.reset_action")}
345
657
  </button>
346
658
  </div>
347
659
  </div>
348
660
  </Modal>
349
661
  </div>
350
662
  );
351
- }
663
+ }