@nastechai/agent 0.16.0 → 0.17.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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,559 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ ChevronDown,
10
+ Pencil,
11
+ Terminal,
12
+ Trash2,
13
+ Users,
14
+ X,
15
+ } from "lucide-react";
16
+ import spinners from "unicode-animations";
17
+ import { H2 } from "@nastechai/ui/ui/components/typography/h2";
18
+ import { api } from "@/lib/api";
19
+ import type { ProfileInfo } from "@/lib/api";
20
+ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
21
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
22
+ import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
23
+ import { useModalBehavior } from "@/hooks/useModalBehavior";
24
+ import { Toast } from "@nastechai/ui/ui/components/toast";
25
+ import { Card, CardContent } from "@nastechai/ui/ui/components/card";
26
+ import { Badge } from "@nastechai/ui/ui/components/badge";
27
+ import { Button } from "@nastechai/ui/ui/components/button";
28
+ import { Input } from "@nastechai/ui/ui/components/input";
29
+ import { Label } from "@nastechai/ui/ui/components/label";
30
+ import { Checkbox } from "@nastechai/ui/ui/components/checkbox";
31
+ import { useI18n } from "@/i18n";
32
+ import { usePageHeader } from "@/contexts/usePageHeader";
33
+ import { cn, themedBody } from "@/lib/utils";
34
+
35
+ // Mirrors nastech_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
36
+ // invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
37
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
38
+
39
+ /** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
40
+ function ProfilesLoadingSpinner() {
41
+ const { frames, interval } = spinners.braille;
42
+ const [frameIndex, setFrameIndex] = useState(0);
43
+
44
+ useEffect(() => {
45
+ if (
46
+ typeof window !== "undefined" &&
47
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
48
+ ) {
49
+ return;
50
+ }
51
+ const id = window.setInterval(
52
+ () => setFrameIndex((i) => (i + 1) % frames.length),
53
+ interval,
54
+ );
55
+ return () => window.clearInterval(id);
56
+ }, [frames.length, interval]);
57
+
58
+ return (
59
+ <span
60
+ aria-hidden
61
+ className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
62
+ >
63
+ {frames[frameIndex]}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ export default function ProfilesPage() {
69
+ const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
70
+ const [loading, setLoading] = useState(true);
71
+ const { toast, showToast } = useToast();
72
+ const { t } = useI18n();
73
+ const { setEnd } = usePageHeader();
74
+
75
+ // Create modal
76
+ const [createModalOpen, setCreateModalOpen] = useState(false);
77
+ const [newName, setNewName] = useState("");
78
+ const [cloneFromDefault, setCloneFromDefault] = useState(true);
79
+ const [creating, setCreating] = useState(false);
80
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
81
+ const createModalRef = useModalBehavior({
82
+ open: createModalOpen,
83
+ onClose: closeCreateModal,
84
+ });
85
+
86
+ // Inline rename state
87
+ const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
88
+ const [renameTo, setRenameTo] = useState("");
89
+
90
+ // Inline SOUL editor state
91
+ const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null);
92
+ const [soulText, setSoulText] = useState("");
93
+ const [soulSaving, setSoulSaving] = useState(false);
94
+ // Tracks the latest SOUL request so out-of-order responses don't overwrite
95
+ // newer state when the user switches profiles or closes the editor.
96
+ const activeSoulRequest = useRef<string | null>(null);
97
+
98
+ const load = useCallback(() => {
99
+ api
100
+ .getProfiles()
101
+ .then((res) => setProfiles(res.profiles))
102
+ .catch((e) => showToast(`${t.status.error}: ${e}`, "error"))
103
+ .finally(() => setLoading(false));
104
+ }, [showToast, t.status.error]);
105
+
106
+ useEffect(() => {
107
+ load();
108
+ }, [load]);
109
+
110
+ const handleCreate = async () => {
111
+ const name = newName.trim();
112
+ if (!name) {
113
+ showToast(t.profiles.nameRequired, "error");
114
+ return;
115
+ }
116
+ if (!PROFILE_NAME_RE.test(name)) {
117
+ showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
118
+ return;
119
+ }
120
+ setCreating(true);
121
+ try {
122
+ await api.createProfile({ name, clone_from_default: cloneFromDefault });
123
+ showToast(`${t.profiles.created}: ${name}`, "success");
124
+ setNewName("");
125
+ setCreateModalOpen(false);
126
+ load();
127
+ } catch (e) {
128
+ showToast(`${t.status.error}: ${e}`, "error");
129
+ } finally {
130
+ setCreating(false);
131
+ }
132
+ };
133
+
134
+ const handleRenameSubmit = async () => {
135
+ if (!renamingFrom) return;
136
+ const target = renameTo.trim();
137
+ if (!target || target === renamingFrom) {
138
+ setRenamingFrom(null);
139
+ setRenameTo("");
140
+ return;
141
+ }
142
+ if (!PROFILE_NAME_RE.test(target)) {
143
+ showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
144
+ return;
145
+ }
146
+ try {
147
+ await api.renameProfile(renamingFrom, target);
148
+ showToast(
149
+ `${t.profiles.renamed}: ${renamingFrom} → ${target}`,
150
+ "success",
151
+ );
152
+ setRenamingFrom(null);
153
+ setRenameTo("");
154
+ load();
155
+ } catch (e) {
156
+ showToast(`${t.status.error}: ${e}`, "error");
157
+ }
158
+ };
159
+
160
+ const openSoulEditor = useCallback(
161
+ async (name: string) => {
162
+ if (editingSoulFor === name) {
163
+ activeSoulRequest.current = null;
164
+ setEditingSoulFor(null);
165
+ return;
166
+ }
167
+ setEditingSoulFor(name);
168
+ setSoulText("");
169
+ activeSoulRequest.current = name;
170
+ try {
171
+ const soul = await api.getProfileSoul(name);
172
+ if (activeSoulRequest.current === name) {
173
+ setSoulText(soul.content);
174
+ }
175
+ } catch (e) {
176
+ if (activeSoulRequest.current === name) {
177
+ showToast(`${t.status.error}: ${e}`, "error");
178
+ }
179
+ }
180
+ },
181
+ [editingSoulFor, showToast, t.status.error],
182
+ );
183
+
184
+ const handleSaveSoul = async (name: string) => {
185
+ setSoulSaving(true);
186
+ try {
187
+ await api.updateProfileSoul(name, soulText);
188
+ showToast(`${t.profiles.soulSaved}: ${name}`, "success");
189
+ } catch (e) {
190
+ showToast(`${t.status.error}: ${e}`, "error");
191
+ } finally {
192
+ setSoulSaving(false);
193
+ }
194
+ };
195
+
196
+ const handleCopyTerminalCommand = async (name: string) => {
197
+ let cmd: string;
198
+ try {
199
+ const res = await api.getProfileSetupCommand(name);
200
+ cmd = res.command;
201
+ } catch (e) {
202
+ showToast(`${t.status.error}: ${e}`, "error");
203
+ return;
204
+ }
205
+ try {
206
+ await navigator.clipboard.writeText(cmd);
207
+ showToast(`${t.profiles.commandCopied}: ${cmd}`, "success");
208
+ } catch {
209
+ showToast(`${t.profiles.copyFailed}: ${cmd}`, "error");
210
+ }
211
+ };
212
+
213
+ const profileDelete = useConfirmDelete<string>({
214
+ onDelete: useCallback(
215
+ async (name: string) => {
216
+ try {
217
+ await api.deleteProfile(name);
218
+ showToast(`${t.profiles.deleted}: ${name}`, "success");
219
+ load();
220
+ } catch (e) {
221
+ showToast(`${t.status.error}: ${e}`, "error");
222
+ throw e;
223
+ }
224
+ },
225
+ [load, showToast, t.profiles.deleted, t.status.error],
226
+ ),
227
+ });
228
+
229
+ const pendingName = profileDelete.pendingId;
230
+
231
+ // Put "Create" button in page header
232
+ useLayoutEffect(() => {
233
+ setEnd(
234
+ <Button
235
+ className="uppercase"
236
+ size="sm"
237
+ onClick={() => setCreateModalOpen(true)}
238
+ >
239
+ {t.common.create}
240
+ </Button>,
241
+ );
242
+ return () => {
243
+ setEnd(null);
244
+ };
245
+ }, [setEnd, t.common.create, loading]);
246
+
247
+ if (loading) {
248
+ return (
249
+ <div
250
+ aria-busy="true"
251
+ aria-live="polite"
252
+ className="flex items-center justify-center py-24"
253
+ >
254
+ <span className="sr-only">{t.common.loading}</span>
255
+
256
+ <ProfilesLoadingSpinner />
257
+ </div>
258
+ );
259
+ }
260
+
261
+ return (
262
+ <div className="flex flex-col gap-6">
263
+ <Toast toast={toast} />
264
+
265
+ <DeleteConfirmDialog
266
+ open={profileDelete.isOpen}
267
+ onCancel={profileDelete.cancel}
268
+ onConfirm={profileDelete.confirm}
269
+ title={t.profiles.confirmDeleteTitle}
270
+ description={
271
+ pendingName
272
+ ? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
273
+ : t.profiles.confirmDeleteMessage
274
+ }
275
+ loading={profileDelete.isDeleting}
276
+ />
277
+
278
+ {/* Create profile modal */}
279
+ {createModalOpen && (
280
+ <div
281
+ ref={createModalRef}
282
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
283
+ onClick={(e) =>
284
+ e.target === e.currentTarget && setCreateModalOpen(false)
285
+ }
286
+ role="dialog"
287
+ aria-modal="true"
288
+ aria-labelledby="create-profile-title"
289
+ >
290
+ <div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
291
+ <Button
292
+ ghost
293
+ size="icon"
294
+ onClick={() => setCreateModalOpen(false)}
295
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
296
+ aria-label="Close"
297
+ >
298
+ <X />
299
+ </Button>
300
+
301
+ <header className="p-5 pb-3 border-b border-border">
302
+ <h2
303
+ id="create-profile-title"
304
+ className="font-mondwest text-display text-base tracking-wider"
305
+ >
306
+ {t.profiles.newProfile}
307
+ </h2>
308
+ </header>
309
+
310
+ <div className="p-5 grid gap-4">
311
+ <div className="grid gap-2">
312
+ <Label htmlFor="profile-name">{t.profiles.name}</Label>
313
+ <Input
314
+ id="profile-name"
315
+ autoFocus
316
+ placeholder={t.profiles.namePlaceholder}
317
+ value={newName}
318
+ onChange={(e) => setNewName(e.target.value)}
319
+ onKeyDown={(e) => {
320
+ if (e.key === "Enter") handleCreate();
321
+ }}
322
+ aria-invalid={
323
+ newName.trim() !== "" &&
324
+ !PROFILE_NAME_RE.test(newName.trim())
325
+ }
326
+ />
327
+ <p className="text-xs text-muted-foreground">
328
+ {t.profiles.nameRule}
329
+ </p>
330
+ </div>
331
+
332
+ <div className="flex items-center gap-2.5">
333
+ <Checkbox
334
+ checked={cloneFromDefault}
335
+ id="clone-from-default"
336
+ onCheckedChange={(checked) =>
337
+ setCloneFromDefault(checked === true)
338
+ }
339
+ />
340
+
341
+ <Label
342
+ className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
343
+ htmlFor="clone-from-default"
344
+ >
345
+ {t.profiles.cloneFromDefault}
346
+ </Label>
347
+ </div>
348
+
349
+ <div className="flex justify-end">
350
+ <Button
351
+ className="uppercase"
352
+ size="sm"
353
+ onClick={handleCreate}
354
+ disabled={creating}
355
+ >
356
+ {creating ? t.common.creating : t.common.create}
357
+ </Button>
358
+ </div>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ )}
363
+
364
+ {/* List */}
365
+ <div className="flex flex-col gap-3">
366
+ <H2
367
+ variant="sm"
368
+ className="flex items-center gap-2 text-muted-foreground"
369
+ >
370
+ <Users className="h-4 w-4" />
371
+ {t.profiles.allProfiles} ({profiles.length})
372
+ </H2>
373
+
374
+ {profiles.length === 0 && (
375
+ <Card>
376
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
377
+ {t.profiles.noProfiles}
378
+ </CardContent>
379
+ </Card>
380
+ )}
381
+
382
+ {profiles.map((p) => {
383
+ const isRenaming = renamingFrom === p.name;
384
+ const isEditingSoul = editingSoulFor === p.name;
385
+ return (
386
+ <Card key={p.name}>
387
+ <CardContent className="flex items-start gap-4 py-4">
388
+ <div className="flex-1 min-w-0">
389
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
390
+ {isRenaming ? (
391
+ <Input
392
+ autoFocus
393
+ value={renameTo}
394
+ onChange={(e) => setRenameTo(e.target.value)}
395
+ onKeyDown={(e) => {
396
+ if (e.key === "Enter") handleRenameSubmit();
397
+ if (e.key === "Escape") setRenamingFrom(null);
398
+ }}
399
+ aria-invalid={
400
+ renameTo.trim() !== "" &&
401
+ renameTo.trim() !== p.name &&
402
+ !PROFILE_NAME_RE.test(renameTo.trim())
403
+ }
404
+ className="max-w-xs"
405
+ />
406
+ ) : (
407
+ <span className="font-medium text-sm truncate">
408
+ {p.name}
409
+ </span>
410
+ )}
411
+ {p.is_default && (
412
+ <Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
413
+ )}
414
+ {p.has_env && (
415
+ <Badge tone="outline">{t.profiles.hasEnv}</Badge>
416
+ )}
417
+ </div>
418
+ {isRenaming &&
419
+ (() => {
420
+ const trimmed = renameTo.trim();
421
+ const invalid =
422
+ trimmed !== "" &&
423
+ trimmed !== p.name &&
424
+ !PROFILE_NAME_RE.test(trimmed);
425
+ return (
426
+ <p
427
+ className={
428
+ "text-xs mb-1 " +
429
+ (invalid
430
+ ? "text-destructive"
431
+ : "text-muted-foreground")
432
+ }
433
+ >
434
+ {invalid
435
+ ? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
436
+ : t.profiles.nameRule}
437
+ </p>
438
+ );
439
+ })()}
440
+ <div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
441
+ {p.model && (
442
+ <span>
443
+ {t.profiles.model}: {p.model}
444
+ {p.provider ? ` (${p.provider})` : ""}
445
+ </span>
446
+ )}
447
+ <span>
448
+ {t.profiles.skills}: {p.skill_count}
449
+ </span>
450
+ <span className="font-mono truncate max-w-[28rem]">
451
+ {p.path}
452
+ </span>
453
+ </div>
454
+ </div>
455
+
456
+ <div className="flex items-center gap-1 shrink-0">
457
+ {isRenaming ? (
458
+ <>
459
+ <Button size="sm" onClick={handleRenameSubmit}>
460
+ {t.common.save}
461
+ </Button>
462
+ <Button
463
+ size="sm"
464
+ ghost
465
+ onClick={() => setRenamingFrom(null)}
466
+ >
467
+ {t.common.cancel}
468
+ </Button>
469
+ </>
470
+ ) : (
471
+ <>
472
+ <Button
473
+ ghost
474
+ size="icon"
475
+ title={t.profiles.editSoul}
476
+ aria-label={t.profiles.editSoul}
477
+ onClick={() => openSoulEditor(p.name)}
478
+ >
479
+ {isEditingSoul ? (
480
+ <ChevronDown className="h-4 w-4" />
481
+ ) : (
482
+ <span aria-hidden className="text-xs font-bold">
483
+ S
484
+ </span>
485
+ )}
486
+ </Button>
487
+ <Button
488
+ ghost
489
+ size="icon"
490
+ title={t.profiles.openInTerminal}
491
+ aria-label={t.profiles.openInTerminal}
492
+ onClick={() => handleCopyTerminalCommand(p.name)}
493
+ >
494
+ <Terminal className="h-4 w-4" />
495
+ </Button>
496
+ {!p.is_default && (
497
+ <Button
498
+ ghost
499
+ size="icon"
500
+ title={t.profiles.rename}
501
+ aria-label={t.profiles.rename}
502
+ onClick={() => {
503
+ setRenamingFrom(p.name);
504
+ setRenameTo(p.name);
505
+ }}
506
+ >
507
+ <Pencil className="h-4 w-4" />
508
+ </Button>
509
+ )}
510
+ {!p.is_default && (
511
+ <Button
512
+ ghost
513
+ size="icon"
514
+ title={t.common.delete}
515
+ aria-label={t.common.delete}
516
+ onClick={() => profileDelete.requestDelete(p.name)}
517
+ >
518
+ <Trash2 className="h-4 w-4 text-destructive" />
519
+ </Button>
520
+ )}
521
+ </>
522
+ )}
523
+ </div>
524
+ </CardContent>
525
+
526
+ {isEditingSoul && (
527
+ <div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
528
+ <Label
529
+ htmlFor={`soul-editor-${p.name}`}
530
+ className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
531
+ >
532
+ {t.profiles.soulSection}
533
+ </Label>
534
+ <textarea
535
+ id={`soul-editor-${p.name}`}
536
+ className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
537
+ placeholder={t.profiles.soulPlaceholder}
538
+ value={soulText}
539
+ onChange={(e) => setSoulText(e.target.value)}
540
+ />
541
+ <div>
542
+ <Button
543
+ size="sm"
544
+ className="uppercase"
545
+ onClick={() => handleSaveSoul(p.name)}
546
+ disabled={soulSaving}
547
+ >
548
+ {soulSaving ? t.common.saving : t.common.save}
549
+ </Button>
550
+ </div>
551
+ </div>
552
+ )}
553
+ </Card>
554
+ );
555
+ })}
556
+ </div>
557
+ </div>
558
+ );
559
+ }