@schlessera/brain-ui-react 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/components/chat/chat-page.js +2 -2
  2. package/dist/components/chat/chat-page.js.map +1 -1
  3. package/dist/components/chat/tool-call-timeline.d.ts +1 -1
  4. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  5. package/dist/components/chat/tool-call-timeline.js +1 -1
  6. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  7. package/dist/components/settings/models-tab.d.ts.map +1 -1
  8. package/dist/components/settings/models-tab.js +2 -1
  9. package/dist/components/settings/models-tab.js.map +1 -1
  10. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  11. package/dist/components/settings/settings-panel.js +4 -2
  12. package/dist/components/settings/settings-panel.js.map +1 -1
  13. package/dist/components/settings/skills-tab.d.ts +4 -0
  14. package/dist/components/settings/skills-tab.d.ts.map +1 -0
  15. package/dist/components/settings/skills-tab.js +150 -0
  16. package/dist/components/settings/skills-tab.js.map +1 -0
  17. package/dist/components/settings/tool-permissions.d.ts +10 -0
  18. package/dist/components/settings/tool-permissions.d.ts.map +1 -0
  19. package/dist/components/settings/tool-permissions.js +41 -0
  20. package/dist/components/settings/tool-permissions.js.map +1 -0
  21. package/dist/hooks/use-websocket.js +1 -1
  22. package/dist/hooks/use-websocket.js.map +1 -1
  23. package/dist/lib/api-client.d.ts +65 -0
  24. package/dist/lib/api-client.d.ts.map +1 -1
  25. package/dist/lib/api-client.js +46 -0
  26. package/dist/lib/api-client.js.map +1 -1
  27. package/dist/stores/chat-store.d.ts +6 -1
  28. package/dist/stores/chat-store.d.ts.map +1 -1
  29. package/dist/stores/chat-store.js +2 -1
  30. package/dist/stores/chat-store.js.map +1 -1
  31. package/dist/stores/ui-store.d.ts +1 -1
  32. package/dist/stores/ui-store.d.ts.map +1 -1
  33. package/dist/styles.css +1 -1
  34. package/package.json +2 -2
  35. package/src/components/chat/chat-page.tsx +2 -2
  36. package/src/components/chat/tool-call-timeline.tsx +16 -4
  37. package/src/components/settings/models-tab.tsx +3 -0
  38. package/src/components/settings/settings-panel.tsx +5 -1
  39. package/src/components/settings/skills-tab.tsx +389 -0
  40. package/src/components/settings/tool-permissions.tsx +72 -0
  41. package/src/hooks/use-websocket.ts +1 -1
  42. package/src/lib/api-client.ts +94 -0
  43. package/src/stores/chat-store.ts +9 -2
  44. package/src/stores/ui-store.ts +1 -1
@@ -0,0 +1,389 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Check, Download, Loader2, Pencil, Plus, Power, Trash2, Upload, X } from "lucide-react";
3
+ import { api, type SkillDetail, type SkillEntry, type SkillInstallOutcome } from "../../lib/api-client.js";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ /**
7
+ * Settings → Skills: manage the user's CUSTOM skills — create, edit,
8
+ * enable/disable, remove — plus a read-only view of the built-ins brain-kit
9
+ * ships. Custom skills live in the brain repo's `.agents/skills/` (real
10
+ * directories), so they persist across deployments, ride the repo's git
11
+ * backup, and reach every backend; a save runs `brain skills sync`
12
+ * server-side so the change applies to the next turn without a restart.
13
+ *
14
+ * For guided authoring there is a better surface than this editor: the
15
+ * `add-skill` skill — ask the agent in chat.
16
+ */
17
+
18
+ const TEMPLATE = (name: string) => `---
19
+ name: ${name}
20
+ description: Use when … (write the TRIGGER, not a summary — this line is how agents decide to load the skill)
21
+ ---
22
+
23
+ # ${name}
24
+
25
+ Instructions for the agent. Keep them imperative and specific.
26
+ `;
27
+
28
+ export function SkillsTab({ active }: { active: boolean }) {
29
+ const [skills, setSkills] = useState<SkillEntry[]>([]);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const [warning, setWarning] = useState<string | null>(null);
32
+ const [busy, setBusy] = useState<string | null>(null);
33
+ const [editing, setEditing] = useState<{
34
+ name: string;
35
+ content: string;
36
+ isNew: boolean;
37
+ readOnly: boolean;
38
+ } | null>(null);
39
+ const [newName, setNewName] = useState("");
40
+ const [githubSource, setGithubSource] = useState("");
41
+ const [overwrite, setOverwrite] = useState(false);
42
+ const [installing, setInstalling] = useState(false);
43
+ const [outcomes, setOutcomes] = useState<SkillInstallOutcome[] | null>(null);
44
+
45
+ async function reload() {
46
+ try {
47
+ const { skills } = await api.skillsList();
48
+ setSkills(skills);
49
+ setError(null);
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : "Could not load skills");
52
+ }
53
+ }
54
+
55
+ useEffect(() => {
56
+ if (active) void reload();
57
+ }, [active]);
58
+
59
+ async function run(name: string, fn: () => Promise<{ warning?: string } | void>) {
60
+ setBusy(name);
61
+ setError(null);
62
+ setWarning(null);
63
+ try {
64
+ const result = await fn();
65
+ if (result && "warning" in result && result.warning) setWarning(result.warning);
66
+ await reload();
67
+ } catch (err) {
68
+ setError(err instanceof Error ? err.message : "Skill operation failed");
69
+ } finally {
70
+ setBusy(null);
71
+ }
72
+ }
73
+
74
+ async function openEditor(entry: SkillEntry) {
75
+ setBusy(entry.name);
76
+ try {
77
+ const detail: SkillDetail = await api.skillGet(entry.name);
78
+ setEditing({
79
+ name: entry.name,
80
+ content: detail.content,
81
+ isNew: false,
82
+ readOnly: entry.source === "builtin",
83
+ });
84
+ setError(null);
85
+ } catch (err) {
86
+ setError(err instanceof Error ? err.message : "Could not load skill");
87
+ } finally {
88
+ setBusy(null);
89
+ }
90
+ }
91
+
92
+ function startCreate() {
93
+ const name = newName.trim();
94
+ if (!name) return;
95
+ setEditing({ name, content: TEMPLATE(name), isNew: true, readOnly: false });
96
+ setNewName("");
97
+ }
98
+
99
+ async function saveEditor() {
100
+ if (!editing || editing.readOnly) return;
101
+ const { name, content, isNew } = editing;
102
+ await run(name, async () => {
103
+ const result = isNew
104
+ ? await api.skillCreate(name, content)
105
+ : await api.skillUpdate(name, content);
106
+ setEditing(null);
107
+ return result;
108
+ });
109
+ }
110
+
111
+ async function install(fn: () => Promise<{ outcomes: SkillInstallOutcome[]; warning?: string }>) {
112
+ setInstalling(true);
113
+ setError(null);
114
+ setWarning(null);
115
+ setOutcomes(null);
116
+ try {
117
+ const result = await fn();
118
+ setOutcomes(result.outcomes);
119
+ if (result.warning) setWarning(result.warning);
120
+ await reload();
121
+ } catch (err) {
122
+ setError(err instanceof Error ? err.message : "Install failed");
123
+ } finally {
124
+ setInstalling(false);
125
+ }
126
+ }
127
+
128
+ const custom = skills.filter((s) => s.source === "custom");
129
+ const builtin = skills.filter((s) => s.source === "builtin");
130
+
131
+ if (editing) {
132
+ return (
133
+ <div className="flex h-full flex-col p-4">
134
+ <div className="flex items-center justify-between gap-2">
135
+ <h3 className="text-sm font-medium text-foreground">
136
+ {editing.isNew ? "New skill" : editing.name}
137
+ {editing.readOnly && (
138
+ <span className="ml-2 text-[11px] text-muted-foreground">built-in · read-only</span>
139
+ )}
140
+ </h3>
141
+ <div className="flex gap-2">
142
+ {!editing.readOnly && (
143
+ <button
144
+ onClick={() => void saveEditor()}
145
+ disabled={busy !== null}
146
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
147
+ >
148
+ {busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
149
+ Save
150
+ </button>
151
+ )}
152
+ <button
153
+ onClick={() => setEditing(null)}
154
+ className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
155
+ >
156
+ <X className="h-3 w-3" />
157
+ Close
158
+ </button>
159
+ </div>
160
+ </div>
161
+ {error && <p className="mt-2 text-xs text-destructive">{error}</p>}
162
+ <textarea
163
+ value={editing.content}
164
+ readOnly={editing.readOnly}
165
+ onChange={(e) => setEditing({ ...editing, content: e.target.value })}
166
+ spellCheck={false}
167
+ className="mt-3 min-h-0 flex-1 resize-none rounded-lg border border-border-subtle bg-background p-3 font-mono text-xs text-foreground focus:border-primary focus:outline-none"
168
+ />
169
+ </div>
170
+ );
171
+ }
172
+
173
+ return (
174
+ <div className="h-full overflow-y-auto p-4">
175
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
176
+ Your skills
177
+ </h3>
178
+ <p className="mt-1 text-xs text-muted-foreground">
179
+ Custom skills live in your brain repo (<code>.agents/skills/</code>) — they
180
+ survive redeployments, ride the repo's backups, and apply to every
181
+ backend. For guided authoring, ask the agent in chat to{" "}
182
+ <span className="font-medium text-foreground">add a skill</span> — the
183
+ built-in <code>add-skill</code> skill walks through design and setup.
184
+ </p>
185
+
186
+ <div className="mt-4 flex items-center gap-2">
187
+ <input
188
+ value={newName}
189
+ onChange={(e) => setNewName(e.target.value.toLowerCase())}
190
+ onKeyDown={(e) => {
191
+ if (e.key === "Enter") startCreate();
192
+ }}
193
+ placeholder="new-skill-name (kebab-case)"
194
+ className="min-w-0 flex-1 rounded-md border border-border-subtle bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
195
+ />
196
+ <button
197
+ onClick={startCreate}
198
+ disabled={!newName.trim()}
199
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
200
+ >
201
+ <Plus className="h-3 w-3" />
202
+ Create
203
+ </button>
204
+ </div>
205
+
206
+ <div className="mt-3 rounded-lg border border-border-subtle bg-surface p-3">
207
+ <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
208
+ Install
209
+ </p>
210
+ <div className="mt-2 flex items-center gap-2">
211
+ <label
212
+ className={cn(
213
+ "flex cursor-pointer items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary",
214
+ installing && "pointer-events-none opacity-50"
215
+ )}
216
+ >
217
+ <Upload className="h-3 w-3" />
218
+ Upload .zip
219
+ <input
220
+ type="file"
221
+ accept=".zip,application/zip"
222
+ className="hidden"
223
+ onChange={(e) => {
224
+ const file = e.target.files?.[0];
225
+ e.target.value = "";
226
+ if (file) void install(() => api.skillInstallZip(file, overwrite));
227
+ }}
228
+ />
229
+ </label>
230
+ <label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
231
+ <input
232
+ type="checkbox"
233
+ checked={overwrite}
234
+ onChange={(e) => setOverwrite(e.target.checked)}
235
+ />
236
+ overwrite existing
237
+ </label>
238
+ </div>
239
+ <div className="mt-2 flex items-center gap-2">
240
+ <input
241
+ value={githubSource}
242
+ onChange={(e) => setGithubSource(e.target.value)}
243
+ onKeyDown={(e) => {
244
+ if (e.key === "Enter" && githubSource.trim() && !installing) {
245
+ void install(() => api.skillInstallGitHub(githubSource.trim(), overwrite));
246
+ }
247
+ }}
248
+ placeholder="GitHub: owner/repo or https://github.com/…/tree/main/skills"
249
+ className="min-w-0 flex-1 rounded-md border border-border-subtle bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
250
+ />
251
+ <button
252
+ onClick={() =>
253
+ void install(() => api.skillInstallGitHub(githubSource.trim(), overwrite))
254
+ }
255
+ disabled={installing || !githubSource.trim()}
256
+ className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
257
+ >
258
+ {installing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
259
+ Install
260
+ </button>
261
+ </div>
262
+ <p className="mt-1.5 text-[11px] text-muted-foreground">
263
+ A skill is any folder with a SKILL.md; one source may carry several.
264
+ Private repos use the server's GITHUB_TOKEN.
265
+ </p>
266
+ {outcomes && (
267
+ <ul className="mt-2 flex flex-col gap-1">
268
+ {outcomes.map((o, i) => (
269
+ <li key={`${o.name}-${i}`} className="text-[11px]">
270
+ {o.status === "skipped" ? (
271
+ <span className="text-muted-foreground">
272
+ ✗ {o.name} — {o.reason}
273
+ </span>
274
+ ) : (
275
+ <span className="text-primary">
276
+ ✓ {o.name} {o.status === "replaced" ? "replaced" : "installed"}
277
+ {typeof o.files === "number" ? ` (${o.files} file${o.files === 1 ? "" : "s"})` : ""}
278
+ </span>
279
+ )}
280
+ </li>
281
+ ))}
282
+ </ul>
283
+ )}
284
+ </div>
285
+
286
+ {error && <p className="mt-2 text-xs text-destructive">{error}</p>}
287
+ {warning && <p className="mt-2 text-xs text-muted-foreground">{warning}</p>}
288
+
289
+ <ul className="mt-4 flex flex-col gap-2">
290
+ {custom.length === 0 && (
291
+ <li className="rounded-lg border border-dashed border-border-subtle p-3 text-xs text-muted-foreground">
292
+ No custom skills yet.
293
+ </li>
294
+ )}
295
+ {custom.map((skill) => (
296
+ <li
297
+ key={skill.name}
298
+ className={cn(
299
+ "rounded-lg border border-border-subtle bg-surface p-3",
300
+ !skill.enabled && "opacity-60"
301
+ )}
302
+ >
303
+ <div className="flex items-center gap-2">
304
+ <div className="min-w-0 flex-1">
305
+ <p className="truncate text-sm text-foreground">
306
+ {skill.name}
307
+ {!skill.enabled && (
308
+ <span className="ml-2 text-[11px] text-muted-foreground">disabled</span>
309
+ )}
310
+ </p>
311
+ <p className="truncate text-[11px] text-muted-foreground">
312
+ {skill.warning ? `⚠ ${skill.warning}` : skill.description}
313
+ </p>
314
+ </div>
315
+ <button
316
+ onClick={() => void openEditor(skill)}
317
+ disabled={busy !== null}
318
+ title="Edit SKILL.md"
319
+ className="rounded-md border border-border-subtle p-1.5 text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
320
+ >
321
+ <Pencil className="h-3.5 w-3.5" />
322
+ </button>
323
+ <button
324
+ onClick={() =>
325
+ void run(skill.name, () => api.skillSetEnabled(skill.name, !skill.enabled))
326
+ }
327
+ disabled={busy !== null}
328
+ title={skill.enabled ? "Disable (all backends)" : "Enable"}
329
+ className={cn(
330
+ "rounded-md border border-border-subtle p-1.5 transition-colors disabled:opacity-50",
331
+ skill.enabled
332
+ ? "text-primary hover:border-primary"
333
+ : "text-muted-foreground hover:border-primary hover:text-primary"
334
+ )}
335
+ >
336
+ {busy === skill.name ? (
337
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
338
+ ) : (
339
+ <Power className="h-3.5 w-3.5" />
340
+ )}
341
+ </button>
342
+ <button
343
+ onClick={() => {
344
+ if (confirm(`Delete the skill "${skill.name}" permanently?`)) {
345
+ void run(skill.name, () => api.skillRemove(skill.name));
346
+ }
347
+ }}
348
+ disabled={busy !== null}
349
+ title="Delete permanently"
350
+ className="rounded-md border border-border-subtle p-1.5 text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50"
351
+ >
352
+ <Trash2 className="h-3.5 w-3.5" />
353
+ </button>
354
+ </div>
355
+ </li>
356
+ ))}
357
+ </ul>
358
+
359
+ <h3 className="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground">
360
+ Built-in skills
361
+ </h3>
362
+ <p className="mt-1 text-xs text-muted-foreground">
363
+ Shipped by brain-kit and its modules; managed by <code>brain skills sync</code>.
364
+ A custom skill with the same name overrides a built-in.
365
+ </p>
366
+ <ul className="mt-3 flex flex-col gap-1.5">
367
+ {builtin.map((skill) => (
368
+ <li
369
+ key={skill.name}
370
+ className="flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2"
371
+ >
372
+ <div className="min-w-0 flex-1">
373
+ <p className="truncate text-xs text-foreground">{skill.name}</p>
374
+ <p className="truncate text-[11px] text-muted-foreground">{skill.description}</p>
375
+ </div>
376
+ <button
377
+ onClick={() => void openEditor(skill)}
378
+ disabled={busy !== null}
379
+ title="View SKILL.md"
380
+ className="shrink-0 rounded-md border border-border-subtle px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
381
+ >
382
+ View
383
+ </button>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </div>
388
+ );
389
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from "react";
2
+ import { X } from "lucide-react";
3
+ import { api } from "../../lib/api-client.js";
4
+
5
+ /**
6
+ * The user's remembered "always allow" tool grants — accumulated by the
7
+ * approval cards' "Always allow" button, revocable here. Renders nothing
8
+ * while the list is empty: the section only exists once there is something
9
+ * to manage.
10
+ */
11
+ export function ToolPermissionsSection({ active }: { active: boolean }) {
12
+ const [tools, setTools] = useState<string[]>([]);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [busy, setBusy] = useState<string | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (!active) return;
18
+ api
19
+ .toolPermissions()
20
+ .then(({ tools }) => setTools(tools))
21
+ .catch((err) =>
22
+ setError(err instanceof Error ? err.message : "Could not load tool permissions")
23
+ );
24
+ }, [active]);
25
+
26
+ async function revoke(tool: string) {
27
+ setBusy(tool);
28
+ setError(null);
29
+ try {
30
+ const { tools } = await api.toolPermissionRevoke(tool);
31
+ setTools(tools);
32
+ } catch (err) {
33
+ setError(err instanceof Error ? err.message : "Could not revoke");
34
+ } finally {
35
+ setBusy(null);
36
+ }
37
+ }
38
+
39
+ if (tools.length === 0 && !error) return null;
40
+
41
+ return (
42
+ <div className="mt-6">
43
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
44
+ Always-allowed tools
45
+ </h3>
46
+ <p className="mt-1 text-xs text-muted-foreground">
47
+ These run without an approval card ("Always allow" on a past approval).
48
+ Destructive command confirmations still ask every time.
49
+ </p>
50
+ <ul className="mt-4 flex flex-col gap-1.5">
51
+ {tools.map((tool) => (
52
+ <li
53
+ key={tool}
54
+ className="flex items-center justify-between gap-3 rounded-lg border border-border-subtle bg-surface px-3 py-2"
55
+ >
56
+ <code className="truncate text-xs text-foreground">{tool}</code>
57
+ <button
58
+ onClick={() => void revoke(tool)}
59
+ disabled={busy === tool}
60
+ title="Ask for approval again"
61
+ className="flex shrink-0 items-center gap-1 rounded-md border border-border-subtle px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50"
62
+ >
63
+ <X className="h-3 w-3" />
64
+ Revoke
65
+ </button>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ {error && <p className="mt-2 text-xs text-destructive">{error}</p>}
70
+ </div>
71
+ );
72
+ }
@@ -256,7 +256,7 @@ export function handleServerMessage(msg: ServerMessage) {
256
256
  break;
257
257
 
258
258
  case "tool_approval_request":
259
- state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description);
259
+ state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description, msg.kind);
260
260
  break;
261
261
 
262
262
  case "tool_result": {
@@ -105,6 +105,30 @@ export interface PiLoginFlow {
105
105
  startedAt: number;
106
106
  }
107
107
 
108
+ /** One managed skill row (mirror of the server view). */
109
+ export interface SkillEntry {
110
+ name: string;
111
+ description: string;
112
+ /** "builtin" = shipped by brain-kit/modules (read-only); "custom" = the user's. */
113
+ source: "builtin" | "custom";
114
+ enabled: boolean;
115
+ warning?: string;
116
+ }
117
+
118
+ /** One skill with its SKILL.md content. */
119
+ export interface SkillDetail extends SkillEntry {
120
+ content: string;
121
+ extraFiles: string[];
122
+ }
123
+
124
+ /** Per-skill result of an archive/GitHub install. */
125
+ export interface SkillInstallOutcome {
126
+ name: string;
127
+ status: "installed" | "replaced" | "skipped";
128
+ reason?: string;
129
+ files?: number;
130
+ }
131
+
108
132
  /** One selectable web-search provider (mirror of the server view). */
109
133
  export interface WebSearchProvider {
110
134
  id: string;
@@ -282,6 +306,76 @@ export const api = {
282
306
  body: JSON.stringify({ providerId }),
283
307
  }),
284
308
 
309
+ /** Custom + built-in skills, as managed from Settings → Skills. */
310
+ skillsList: () => fetchJson<{ skills: SkillEntry[] }>("/skills"),
311
+
312
+ /** One skill's SKILL.md and file list (builtins read-only). */
313
+ skillGet: (name: string) =>
314
+ fetchJson<SkillDetail>(`/skills/${encodeURIComponent(name)}`),
315
+
316
+ /** Create a custom skill; runs `brain skills sync` server-side. */
317
+ skillCreate: (name: string, content: string) =>
318
+ fetchJson<{ skill: SkillEntry; warning?: string }>("/skills", {
319
+ method: "POST",
320
+ body: JSON.stringify({ name, content }),
321
+ }),
322
+
323
+ /** Replace a custom skill's SKILL.md. */
324
+ skillUpdate: (name: string, content: string) =>
325
+ fetchJson<{ skill: SkillEntry; warning?: string }>(`/skills/${encodeURIComponent(name)}`, {
326
+ method: "PUT",
327
+ body: JSON.stringify({ content }),
328
+ }),
329
+
330
+ /** Enable/disable a custom skill (applies to every backend at once). */
331
+ skillSetEnabled: (name: string, enabled: boolean) =>
332
+ fetchJson<{ skill: SkillEntry; warning?: string }>(
333
+ `/skills/${encodeURIComponent(name)}/enabled`,
334
+ { method: "POST", body: JSON.stringify({ enabled }) }
335
+ ),
336
+
337
+ /** Delete a custom skill permanently. */
338
+ skillRemove: (name: string) =>
339
+ fetchJson<{ ok: boolean; warning?: string }>(`/skills/${encodeURIComponent(name)}`, {
340
+ method: "DELETE",
341
+ }),
342
+
343
+ /** Install skill(s) from an uploaded ZIP archive. */
344
+ skillInstallZip: async (file: File, overwrite: boolean) => {
345
+ const form = new FormData();
346
+ form.append("file", file);
347
+ form.append("overwrite", overwrite ? "true" : "false");
348
+ // Raw fetch: the browser must set the multipart boundary itself.
349
+ const res = await fetch(`${apiBase()}/skills/install/zip`, {
350
+ method: "POST",
351
+ body: form,
352
+ });
353
+ const body = (await res.json().catch(() => null)) as
354
+ | { outcomes?: SkillInstallOutcome[]; warning?: string; error?: string }
355
+ | null;
356
+ if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
357
+ return body as { outcomes: SkillInstallOutcome[]; warning?: string };
358
+ },
359
+
360
+ /** Install skill(s) from a GitHub repository (owner/repo or URL). */
361
+ skillInstallGitHub: (source: string, overwrite: boolean, ref?: string) =>
362
+ fetchJson<{ outcomes: SkillInstallOutcome[]; warning?: string }>(
363
+ "/skills/install/github",
364
+ {
365
+ method: "POST",
366
+ body: JSON.stringify({ source, overwrite, ...(ref ? { ref } : {}) }),
367
+ }
368
+ ),
369
+
370
+ /** Tools remembered as "always allow" (auto-approved without a card). */
371
+ toolPermissions: () => fetchJson<{ tools: string[] }>("/tool-permissions"),
372
+
373
+ /** Revoke one remembered tool grant; returns the updated list. */
374
+ toolPermissionRevoke: (tool: string) =>
375
+ fetchJson<{ tools: string[] }>(`/tool-permissions/${encodeURIComponent(tool)}`, {
376
+ method: "DELETE",
377
+ }),
378
+
285
379
  /** Web-search provider config; `configured: false` when pi is not in play. */
286
380
  webSearchConfig: () => fetchJson<WebSearchConfig>("/web-search"),
287
381
 
@@ -50,6 +50,11 @@ export interface ToolCall {
50
50
  output?: string;
51
51
  isError?: boolean;
52
52
  status: "streaming" | "pending_approval" | "approved" | "denied" | "complete";
53
+ /**
54
+ * What the pending approval is for: "tool" may be remembered via "Always
55
+ * allow"; "command" (a destructive-bash confirmation) is per-use only.
56
+ */
57
+ approvalKind?: "tool" | "command";
53
58
  /**
54
59
  * Execution timing for the duration badge. `startedAt` is (re)stamped when
55
60
  * the input finishes streaming or an approval is granted — so approval
@@ -159,7 +164,8 @@ interface ChatState {
159
164
  toolUseId: string,
160
165
  toolName: string,
161
166
  input: Record<string, unknown>,
162
- description?: string
167
+ description?: string,
168
+ kind?: "tool" | "command"
163
169
  ) => void;
164
170
  resolveToolApproval: (key: ChatKey, toolUseId: string, approved: boolean) => void;
165
171
  setToolResult: (key: ChatKey, toolUseId: string, output: string, isError: boolean) => void;
@@ -465,7 +471,7 @@ export const useChatStore = create<ChatState>((set, get) => {
465
471
  ),
466
472
  })),
467
473
 
468
- requestToolApproval: (key, toolUseId, toolName, input, _description) =>
474
+ requestToolApproval: (key, toolUseId, toolName, input, _description, kind) =>
469
475
  mutateLastAssistant(key, (last) => {
470
476
  // Check if tool call already exists (from streaming)
471
477
  const existingIdx = last.toolCalls.findIndex(
@@ -478,6 +484,7 @@ export const useChatStore = create<ChatState>((set, get) => {
478
484
  input,
479
485
  inputJson: JSON.stringify(input, null, 2),
480
486
  status: "pending_approval",
487
+ ...(kind ? { approvalKind: kind } : {}),
481
488
  };
482
489
  let parts = last.parts;
483
490
  if (existingIdx >= 0) {
@@ -1,7 +1,7 @@
1
1
  import { create } from "zustand";
2
2
 
3
3
  /** Which tab the settings panel opens on. */
4
- export type SettingsTab = "models" | "security";
4
+ export type SettingsTab = "models" | "skills" | "security";
5
5
 
6
6
  /** Full-screen surface currently shown inside the AppShell. */
7
7
  export type ActiveView = "chat" | "graph" | "activity";